Merge pull request #3441 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 89b7a3d7fb
This commit is contained in:
Claire
2026-03-14 12:55:51 +01:00
committed by GitHub
107 changed files with 1545 additions and 201 deletions

View File

@@ -11,11 +11,14 @@ module BrandingHelper
end
def _logo_as_symbol_wordmark
content_tag(:svg, tag.use(href: '#logo-symbol-wordmark'), viewBox: '0 0 261 66', class: 'logo logo--wordmark')
tag.svg(viewBox: '0 0 261 66', class: 'logo logo--wordmark') do
tag.title('Mastodon') +
tag.use(href: '#logo-symbol-wordmark')
end
end
def _logo_as_symbol_icon
content_tag(:svg, tag.use(href: '#logo-symbol-icon'), viewBox: '0 0 79 79', class: 'logo logo--icon')
tag.svg(tag.use(href: '#logo-symbol-icon'), viewBox: '0 0 79 79', class: 'logo logo--icon')
end
def render_logo

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { A11yLiveRegion } from '.';
const meta = {
title: 'Components/A11yLiveRegion',
component: A11yLiveRegion,
} satisfies Meta<typeof A11yLiveRegion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Polite: Story = {
args: {
children: "This field can't be empty.",
},
};
export const Assertive: Story = {
args: {
...Polite.args,
role: 'alert',
},
};

View File

@@ -0,0 +1,28 @@
import { polymorphicForwardRef } from '@/types/polymorphic';
/**
* A live region is a content region that announces changes of its contents
* to users of assistive technology like screen readers.
*
* Dynamically added warnings, errors, or live status updates should be wrapped
* in a live region to ensure they are not missed when they appear.
*
* **Important:**
* Live regions must be present in the DOM _before_
* the to-be announced content is rendered into it.
*/
export const A11yLiveRegion = polymorphicForwardRef<'div'>(
({ role = 'status', as: Component = 'div', children, ...props }, ref) => {
return (
<Component
role={role}
aria-live={role === 'alert' ? 'assertive' : 'polite'}
ref={ref}
{...props}
>
{children}
</Component>
);
},
);

View File

@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CalloutInline } from '.';
const meta = {
title: 'Components/CalloutInline',
args: {
children: 'Contents here',
},
component: CalloutInline,
} satisfies Meta<typeof CalloutInline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Error: Story = {
args: {
variant: 'error',
},
};
export const Warning: Story = {
args: {
variant: 'warning',
},
};
export const Success: Story = {
args: {
variant: 'success',
},
};
export const Info: Story = {
args: {
variant: 'info',
},
};

View File

@@ -0,0 +1,39 @@
import type { FC } from 'react';
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { Icon } from '../icon';
import classes from './styles.module.css';
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
error: ErrorIcon,
warning: WarningIcon,
info: InfoIcon,
success: CheckIcon,
};
export const CalloutInline: FC<
Partial<FieldStatus> & React.ComponentPropsWithoutRef<'div'>
> = ({ variant = 'error', message, className, children, ...props }) => {
return (
<div
{...props}
className={classNames(className, classes.wrapper)}
data-variant={variant}
>
<Icon id={variant} icon={iconMap[variant]} className={classes.icon} />
{message ?? children}
</div>
);
};

View File

@@ -0,0 +1,29 @@
.wrapper {
display: flex;
align-items: start;
gap: 4px;
font-size: 13px;
font-weight: 500;
&[data-variant='success'] {
color: var(--color-text-success);
}
&[data-variant='warning'] {
color: var(--color-text-warning);
}
&[data-variant='error'] {
color: var(--color-text-error);
}
&[data-variant='info'] {
color: var(--color-text-primary);
}
}
.icon {
width: 16px;
height: 16px;
margin-top: 1px;
}

View File

@@ -76,7 +76,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -13,12 +13,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const CheckboxField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-start'
>

View File

@@ -86,14 +86,14 @@ interface Props<T extends ComboboxItem>
*/
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
{ id, label, hint, status, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}

View File

@@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
(
{ id, label, hint, hasError, value, required, className, ...otherProps },
{ id, label, hint, status, value, required, className, ...otherProps },
ref,
) => {
const intl = useIntl();
@@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => (

View File

@@ -37,7 +37,7 @@ export const EmojiTextInputField: FC<
value,
label,
hint,
hasError,
status,
maxLength,
counterMax = maxLength,
recommended,
@@ -49,7 +49,7 @@ export const EmojiTextInputField: FC<
const wrapperProps = {
label,
hint,
hasError,
status,
counterMax,
recommended,
disabled,
@@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC<
recommended,
disabled,
hint,
hasError,
status,
...otherProps
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC<
const wrapperProps = {
label,
hint,
hasError,
status,
counterMax,
recommended,
disabled,

View File

@@ -1,7 +1,9 @@
.fieldset {
--container-gap: 12px;
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--container-gap);
color: var(--color-text-primary);
font-size: 15px;
}
@@ -17,3 +19,11 @@
column-gap: 24px;
}
}
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
&:empty {
margin-top: calc(-1 * var(--container-gap));
}
}

View File

@@ -3,14 +3,19 @@
import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react';
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
import classes from './fieldset.module.scss';
import { getFieldStatus } from './form_field_wrapper';
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
interface FieldsetProps {
legend: ReactNode;
hint?: ReactNode;
name?: string;
hasError?: boolean;
status?: FieldStatus | FieldStatus['variant'];
layout?: 'vertical' | 'horizontal';
children: ReactNode;
}
@@ -26,22 +31,33 @@ export const Fieldset: FC<FieldsetProps> = ({
legend,
hint,
name,
hasError,
status,
layout,
children,
}) => {
const uniqueId = useId();
const labelId = `${uniqueId}-label`;
const hintId = `${uniqueId}-hint`;
const statusId = `${uniqueId}-status`;
const fieldsetName = name || `${uniqueId}-fieldset-name`;
const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const descriptionIds = [
hasHint ? hintId : '',
hasStatusMessage ? statusId : '',
]
.filter((id) => !!id)
.join(' ');
return (
<fieldset
className={classes.fieldset}
data-has-error={hasError}
data-has-error={status === 'error'}
aria-labelledby={labelId}
aria-describedby={hintId}
aria-describedby={descriptionIds}
>
<div className={formFieldWrapperClasses.labelWrapper}>
<div id={labelId} className={formFieldWrapperClasses.label}>
@@ -59,6 +75,11 @@ export const Fieldset: FC<FieldsetProps> = ({
{children}
</FieldsetNameContext.Provider>
</div>
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</fieldset>
);
};

View File

@@ -46,6 +46,14 @@
font-size: 13px;
}
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
&:empty {
margin-top: calc(-1 * var(--form-field-label-gap));
}
}
.inputWrapper {
display: block;
}

View File

@@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
import { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss';
@@ -20,7 +24,7 @@ interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
hasError?: boolean;
status?: FieldStatus['variant'] | FieldStatus;
inputId?: string;
describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end';
@@ -33,7 +37,7 @@ interface FieldWrapperProps {
*/
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
'label' | 'hint' | 'status'
> & { wrapperClassName?: string };
/**
@@ -48,27 +52,31 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
hint,
describedById,
required,
hasError,
status,
inputPlacement,
children,
className,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
const statusId = `${inputIdProp || uniqueId}-status`;
const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const hasParentFieldset = !!useContext(FieldsetNameContext);
const descriptionIds =
[hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById]
.filter((id) => !!id)
.join(' ') || undefined;
const inputProps: InputProps = {
required,
id: inputId,
'aria-describedby': descriptionIds,
};
if (hasHint) {
inputProps['aria-describedby'] = describedById
? `${describedById} ${hintId}`
: hintId;
}
const input = (
<div className={classes.inputWrapper}>{children(inputProps)}</div>
@@ -77,7 +85,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
return (
<div
className={classNames(classes.wrapper, className)}
data-has-error={hasError}
data-has-error={fieldStatus?.variant === 'error'}
data-input-placement={inputPlacement}
>
{inputPlacement === 'inline-start' && input}
@@ -100,6 +108,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
</div>
{inputPlacement !== 'inline-start' && input}
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</div>
);
};
@@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</>
);
export function getFieldStatus(status: FieldWrapperProps['status']) {
if (!status) {
return null;
}
if (typeof status === 'string') {
const fieldStatus: FieldStatus = {
variant: status,
message: '',
};
return fieldStatus;
}
return status;
}

View File

@@ -71,7 +71,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -15,7 +15,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const RadioButtonField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => {
>(({ id, label, hint, status, required, ...otherProps }, ref) => {
const fieldsetName = useContext(FieldsetNameContext);
return (
@@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef<
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-start'
>

View File

@@ -0,0 +1,128 @@
/*
Inspired by:
https://danielstern.ca/range.css
https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
*/
.input {
--color-bg-thumb: var(--color-bg-brand-base);
--color-bg-thumb-hover: var(--color-bg-brand-base-hover);
--color-bg-track: var(--color-bg-secondary);
width: 100%;
margin: 6px 0;
background-color: transparent;
appearance: none;
&:focus {
outline: none;
}
// Thumb
&::-webkit-slider-thumb {
margin-top: -6px;
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
-webkit-appearance: none;
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
}
&::-ms-thumb {
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
margin-top: 0; // Needed to keep the Edge thumb centred
}
&:focus,
&:hover {
&::-webkit-slider-thumb {
background: var(--color-bg-thumb-hover);
}
&::-moz-range-thumb {
background: var(--color-bg-thumb-hover);
}
&::-ms-thumb {
background: var(--color-bg-thumb-hover);
}
}
&:focus-visible {
&::-webkit-slider-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&::-moz-range-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&::-ms-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}
// Track
&::-webkit-slider-runnable-track {
background: var(--color-bg-track);
border: 0;
border-radius: 1.3px;
width: 100%;
height: 4px;
cursor: pointer;
}
&::-moz-range-track {
background: var(--color-bg-track);
border: 0;
border-radius: 1.3px;
width: 100%;
height: 4px;
cursor: pointer;
}
&::-ms-track {
background: var(--color-bg-track);
border: 0;
color: transparent;
width: 100%;
height: 4px;
cursor: pointer;
}
}
.markers {
display: flex;
flex-direction: column;
justify-content: space-between;
writing-mode: vertical-lr;
width: 100%;
font-size: 11px;
color: var(--color-text-secondary);
user-select: none;
option {
padding: 0;
}
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RangeInputField } from './range_input_field';
const meta = {
title: 'Components/Form Fields/RangeInputField',
component: RangeInputField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
checked: false,
disabled: false,
},
} satisfies Meta<typeof RangeInputField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Markers: Story = {
args: {
markers: [
{ value: 0, label: 'None' },
{ value: 25, label: 'Some' },
{ value: 50, label: 'Half' },
{ value: 75, label: 'Most' },
{ value: 100, label: 'All' },
],
},
};

View File

@@ -0,0 +1,86 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, useId } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './range_input.module.scss';
export type RangeInputProps = Omit<
ComponentPropsWithoutRef<'input'>,
'type' | 'list'
> & {
markers?: { value: number; label: string }[] | number[];
};
interface Props extends RangeInputProps, CommonFieldWrapperProps {}
/**
* A simple form field for single-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
RangeInputField.displayName = 'RangeInputField';
export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(
({ className, markers, id, ...otherProps }, ref) => {
const markersId = useId();
if (!markers) {
return (
<input
{...otherProps}
type='range'
className={classNames(className, classes.input)}
ref={ref}
/>
);
}
return (
<>
<input
{...otherProps}
type='range'
className={classNames(className, classes.input)}
ref={ref}
list={markersId}
/>
<datalist id={markersId} className={classes.markers}>
{markers.map((marker) => {
const value = typeof marker === 'number' ? marker : marker.value;
return (
<option
key={value}
value={value}
label={typeof marker !== 'number' ? marker.label : undefined}
/>
);
})}
</datalist>
</>
);
},
);
RangeInput.displayName = 'RangeInput';

View File

@@ -51,7 +51,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -19,12 +19,12 @@ interface Props
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => (

View File

@@ -38,7 +38,17 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: { variant: 'error', message: "This field can't be empty" },
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
},
};

View File

@@ -26,14 +26,14 @@ export const TextAreaField = forwardRef<
TextAreaProps & CommonFieldWrapperProps
>(
(
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
{ id, label, hint, required, status, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
className={wrapperClassName}
>

View File

@@ -29,16 +29,16 @@
color: var(--color-text-secondary);
}
&:focus {
outline-color: var(--color-text-brand);
}
&:focus:user-invalid,
&:required:user-invalid,
[data-has-error='true'] & {
outline-color: var(--color-text-error);
}
&:focus {
outline-color: var(--color-text-brand);
}
&:required:user-valid {
outline-color: var(--color-text-success);
}

View File

@@ -40,7 +40,17 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
},
};

View File

@@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
className={wrapperClassName}
>

View File

@@ -45,7 +45,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -14,12 +14,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-end'
>

View File

@@ -107,7 +107,7 @@
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
.buttonsDesktop {
@container (width < #{$button-breakpoint}) {
@@ -132,7 +132,7 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
}
@supports (not (container-type: inline-size)) {
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
@media (min-width: ($button-fallback-breakpoint + 1px)) {
display: none;
}
}

View File

@@ -141,8 +141,11 @@ const InnerNodeModal: FC<{
onChange={handleChange}
label={intl.formatMessage(messages.fieldLabel)}
className={classes.noteInput}
hasError={state === 'error'}
hint={errorText}
status={
state === 'error'
? { variant: 'error', message: errorText }
: undefined
}
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
autoFocus
/>

View File

@@ -1,12 +1,15 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
import {
hasSpecialCharacters,
inputToHashtag,
} from '@/flavours/glitch/utils/hashtags';
import type {
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
@@ -31,6 +34,7 @@ import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } =
@@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => {
],
);
const topicHasSpecialCharacters = useMemo(
() => hasSpecialCharacters(topic),
[topic],
);
return (
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
@@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => {
autoCorrect='off'
spellCheck='false'
maxLength={40}
status={
topicHasSpecialCharacters
? {
variant: 'warning',
message: intl.formatMessage({
id: 'collections.topic_special_chars_hint',
defaultMessage:
'Special characters will be removed when saving',
}),
}
: undefined
}
/>
<Fieldset

View File

@@ -233,7 +233,7 @@ export const Profile: React.FC<{
}
value={displayName}
onChange={handleDisplayNameChange}
hasError={!!errors?.display_name}
status={errors?.display_name ? 'error' : undefined}
id='display_name'
/>
</div>
@@ -255,7 +255,7 @@ export const Profile: React.FC<{
}
value={note}
onChange={handleNoteChange}
hasError={!!errors?.note}
status={errors?.note ? 'error' : undefined}
id='note'
/>
</div>

View File

@@ -23,6 +23,7 @@ import {
createAppSelector,
createDataLoadingThunk,
} from '@/flavours/glitch/store/typed_functions';
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
type QueryStatus = 'idle' | 'loading' | 'error';
@@ -82,7 +83,7 @@ const collectionSlice = createSlice({
id: collection?.id ?? null,
name: collection?.name ?? '',
description: collection?.description ?? '',
topic: collection?.tag?.name ?? '',
topic: inputToHashtag(collection?.tag?.name ?? ''),
language: collection?.language ?? '',
discoverable: collection?.discoverable ?? true,
sensitive: collection?.sensitive ?? false,

View File

@@ -8763,7 +8763,7 @@ noscript {
gap: 8px;
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
&--desktop {
margin-top: 55px;
@@ -8787,7 +8787,7 @@ noscript {
}
@supports (not (container-type: inline-size)) {
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
@media (min-width: ($button-fallback-breakpoint + 1px)) {
display: none;
}
}

View File

@@ -13,29 +13,29 @@
.logo-container {
margin: 50px auto;
h1 {
display: flex;
justify-content: center;
align-items: center;
.logo {
height: 42px;
margin-inline-end: 10px;
}
a {
display: flex;
justify-content: center;
align-items: center;
width: min-content;
margin: 0 auto;
padding: 12px 16px;
color: var(--color-text-primary);
text-decoration: none;
outline: 0;
padding: 12px 16px;
line-height: 32px;
font-weight: 500;
font-size: 14px;
&:focus-visible {
outline: var(--outline-focus-default);
}
}
.logo {
height: 42px;
margin-inline-end: 10px;
}
}
.compose-standalone {

View File

@@ -77,7 +77,6 @@ code {
.input {
margin-bottom: 16px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
@@ -472,7 +471,8 @@ code {
}
}
.input.radio_buttons .radio label {
.input.radio_buttons .radio {
label {
margin-bottom: 5px;
font-family: inherit;
font-size: 14px;
@@ -481,6 +481,11 @@ code {
width: auto;
}
input[type='radio'] {
accent-color: var(--color-text-brand);
}
}
.check_boxes {
.checkbox {
label {
@@ -504,6 +509,12 @@ code {
}
}
label.checkbox {
input[type='checkbox'] {
accent-color: var(--color-text-brand);
}
}
.input.static .label_input__wrapper {
font-size: 14px;
padding: 10px;
@@ -524,13 +535,20 @@ code {
color: var(--color-text-primary);
display: block;
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
padding: 10px 16px;
outline: var(--outline-focus-default);
outline-offset: -2px;
outline-color: transparent;
transition: outline-color 0.15s ease-out;
&:focus {
outline: var(--outline-focus-default);
}
&:invalid {
box-shadow: none;
@@ -614,6 +632,11 @@ code {
margin-inline-end: 0;
}
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&:active,
&:focus,
&:hover {
@@ -654,6 +677,11 @@ code {
padding-inline-end: 30px;
height: 41px;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
@media screen and (width <= 600px) {
font-size: 16px;
}

View File

@@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => {
return `#${words.join('')}${trailingSpace}`;
};
export const hasSpecialCharacters = (input: string) => {
// Regex matches any character NOT a letter/digit, except the #
return /[^a-zA-Z0-9# ]/.test(input);
};

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { A11yLiveRegion } from '.';
const meta = {
title: 'Components/A11yLiveRegion',
component: A11yLiveRegion,
} satisfies Meta<typeof A11yLiveRegion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Polite: Story = {
args: {
children: "This field can't be empty.",
},
};
export const Assertive: Story = {
args: {
...Polite.args,
role: 'alert',
},
};

View File

@@ -0,0 +1,28 @@
import { polymorphicForwardRef } from '@/types/polymorphic';
/**
* A live region is a content region that announces changes of its contents
* to users of assistive technology like screen readers.
*
* Dynamically added warnings, errors, or live status updates should be wrapped
* in a live region to ensure they are not missed when they appear.
*
* **Important:**
* Live regions must be present in the DOM _before_
* the to-be announced content is rendered into it.
*/
export const A11yLiveRegion = polymorphicForwardRef<'div'>(
({ role = 'status', as: Component = 'div', children, ...props }, ref) => {
return (
<Component
role={role}
aria-live={role === 'alert' ? 'assertive' : 'polite'}
ref={ref}
{...props}
>
{children}
</Component>
);
},
);

View File

@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CalloutInline } from '.';
const meta = {
title: 'Components/CalloutInline',
args: {
children: 'Contents here',
},
component: CalloutInline,
} satisfies Meta<typeof CalloutInline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Error: Story = {
args: {
variant: 'error',
},
};
export const Warning: Story = {
args: {
variant: 'warning',
},
};
export const Success: Story = {
args: {
variant: 'success',
},
};
export const Info: Story = {
args: {
variant: 'info',
},
};

View File

@@ -0,0 +1,39 @@
import type { FC } from 'react';
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { Icon } from '../icon';
import classes from './styles.module.css';
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
error: ErrorIcon,
warning: WarningIcon,
info: InfoIcon,
success: CheckIcon,
};
export const CalloutInline: FC<
Partial<FieldStatus> & React.ComponentPropsWithoutRef<'div'>
> = ({ variant = 'error', message, className, children, ...props }) => {
return (
<div
{...props}
className={classNames(className, classes.wrapper)}
data-variant={variant}
>
<Icon id={variant} icon={iconMap[variant]} className={classes.icon} />
{message ?? children}
</div>
);
};

View File

@@ -0,0 +1,29 @@
.wrapper {
display: flex;
align-items: start;
gap: 4px;
font-size: 13px;
font-weight: 500;
&[data-variant='success'] {
color: var(--color-text-success);
}
&[data-variant='warning'] {
color: var(--color-text-warning);
}
&[data-variant='error'] {
color: var(--color-text-error);
}
&[data-variant='info'] {
color: var(--color-text-primary);
}
}
.icon {
width: 16px;
height: 16px;
margin-top: 1px;
}

View File

@@ -76,7 +76,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -13,12 +13,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const CheckboxField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-start'
>

View File

@@ -86,14 +86,14 @@ interface Props<T extends ComboboxItem>
*/
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
{ id, label, hint, status, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}

View File

@@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
(
{ id, label, hint, hasError, value, required, className, ...otherProps },
{ id, label, hint, status, value, required, className, ...otherProps },
ref,
) => {
const intl = useIntl();
@@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => (

View File

@@ -37,7 +37,7 @@ export const EmojiTextInputField: FC<
value,
label,
hint,
hasError,
status,
maxLength,
counterMax = maxLength,
recommended,
@@ -49,7 +49,7 @@ export const EmojiTextInputField: FC<
const wrapperProps = {
label,
hint,
hasError,
status,
counterMax,
recommended,
disabled,
@@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC<
recommended,
disabled,
hint,
hasError,
status,
...otherProps
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC<
const wrapperProps = {
label,
hint,
hasError,
status,
counterMax,
recommended,
disabled,

View File

@@ -1,7 +1,9 @@
.fieldset {
--container-gap: 12px;
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--container-gap);
color: var(--color-text-primary);
font-size: 15px;
}
@@ -17,3 +19,11 @@
column-gap: 24px;
}
}
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
&:empty {
margin-top: calc(-1 * var(--container-gap));
}
}

View File

@@ -3,14 +3,19 @@
import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react';
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
import type { FieldStatus } from 'mastodon/components/callout_inline';
import { CalloutInline } from 'mastodon/components/callout_inline';
import classes from './fieldset.module.scss';
import { getFieldStatus } from './form_field_wrapper';
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
interface FieldsetProps {
legend: ReactNode;
hint?: ReactNode;
name?: string;
hasError?: boolean;
status?: FieldStatus | FieldStatus['variant'];
layout?: 'vertical' | 'horizontal';
children: ReactNode;
}
@@ -26,22 +31,33 @@ export const Fieldset: FC<FieldsetProps> = ({
legend,
hint,
name,
hasError,
status,
layout,
children,
}) => {
const uniqueId = useId();
const labelId = `${uniqueId}-label`;
const hintId = `${uniqueId}-hint`;
const statusId = `${uniqueId}-status`;
const fieldsetName = name || `${uniqueId}-fieldset-name`;
const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const descriptionIds = [
hasHint ? hintId : '',
hasStatusMessage ? statusId : '',
]
.filter((id) => !!id)
.join(' ');
return (
<fieldset
className={classes.fieldset}
data-has-error={hasError}
data-has-error={status === 'error'}
aria-labelledby={labelId}
aria-describedby={hintId}
aria-describedby={descriptionIds}
>
<div className={formFieldWrapperClasses.labelWrapper}>
<div id={labelId} className={formFieldWrapperClasses.label}>
@@ -59,6 +75,11 @@ export const Fieldset: FC<FieldsetProps> = ({
{children}
</FieldsetNameContext.Provider>
</div>
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</fieldset>
);
};

View File

@@ -46,6 +46,14 @@
font-size: 13px;
}
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
&:empty {
margin-top: calc(-1 * var(--form-field-label-gap));
}
}
.inputWrapper {
display: block;
}

View File

@@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
import type { FieldStatus } from 'mastodon/components/callout_inline';
import { CalloutInline } from 'mastodon/components/callout_inline';
import { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss';
@@ -20,7 +24,7 @@ interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
hasError?: boolean;
status?: FieldStatus['variant'] | FieldStatus;
inputId?: string;
describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end';
@@ -33,7 +37,7 @@ interface FieldWrapperProps {
*/
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
'label' | 'hint' | 'status'
> & { wrapperClassName?: string };
/**
@@ -48,27 +52,31 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
hint,
describedById,
required,
hasError,
status,
inputPlacement,
children,
className,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
const statusId = `${inputIdProp || uniqueId}-status`;
const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const hasParentFieldset = !!useContext(FieldsetNameContext);
const descriptionIds =
[hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById]
.filter((id) => !!id)
.join(' ') || undefined;
const inputProps: InputProps = {
required,
id: inputId,
'aria-describedby': descriptionIds,
};
if (hasHint) {
inputProps['aria-describedby'] = describedById
? `${describedById} ${hintId}`
: hintId;
}
const input = (
<div className={classes.inputWrapper}>{children(inputProps)}</div>
@@ -77,7 +85,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
return (
<div
className={classNames(classes.wrapper, className)}
data-has-error={hasError}
data-has-error={fieldStatus?.variant === 'error'}
data-input-placement={inputPlacement}
>
{inputPlacement === 'inline-start' && input}
@@ -100,6 +108,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
</div>
{inputPlacement !== 'inline-start' && input}
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</div>
);
};
@@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</>
);
export function getFieldStatus(status: FieldWrapperProps['status']) {
if (!status) {
return null;
}
if (typeof status === 'string') {
const fieldStatus: FieldStatus = {
variant: status,
message: '',
};
return fieldStatus;
}
return status;
}

View File

@@ -71,7 +71,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -15,7 +15,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const RadioButtonField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => {
>(({ id, label, hint, status, required, ...otherProps }, ref) => {
const fieldsetName = useContext(FieldsetNameContext);
return (
@@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef<
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-start'
>

View File

@@ -0,0 +1,128 @@
/*
Inspired by:
https://danielstern.ca/range.css
https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
*/
.input {
--color-bg-thumb: var(--color-bg-brand-base);
--color-bg-thumb-hover: var(--color-bg-brand-base-hover);
--color-bg-track: var(--color-bg-secondary);
width: 100%;
margin: 6px 0;
background-color: transparent;
appearance: none;
&:focus {
outline: none;
}
// Thumb
&::-webkit-slider-thumb {
margin-top: -6px;
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
-webkit-appearance: none;
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
}
&::-ms-thumb {
width: 16px;
height: 16px;
background: var(--color-bg-thumb);
border: 0;
border-radius: 50px;
cursor: pointer;
margin-top: 0; // Needed to keep the Edge thumb centred
}
&:focus,
&:hover {
&::-webkit-slider-thumb {
background: var(--color-bg-thumb-hover);
}
&::-moz-range-thumb {
background: var(--color-bg-thumb-hover);
}
&::-ms-thumb {
background: var(--color-bg-thumb-hover);
}
}
&:focus-visible {
&::-webkit-slider-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&::-moz-range-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&::-ms-thumb {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}
// Track
&::-webkit-slider-runnable-track {
background: var(--color-bg-track);
border: 0;
border-radius: 1.3px;
width: 100%;
height: 4px;
cursor: pointer;
}
&::-moz-range-track {
background: var(--color-bg-track);
border: 0;
border-radius: 1.3px;
width: 100%;
height: 4px;
cursor: pointer;
}
&::-ms-track {
background: var(--color-bg-track);
border: 0;
color: transparent;
width: 100%;
height: 4px;
cursor: pointer;
}
}
.markers {
display: flex;
flex-direction: column;
justify-content: space-between;
writing-mode: vertical-lr;
width: 100%;
font-size: 11px;
color: var(--color-text-secondary);
user-select: none;
option {
padding: 0;
}
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RangeInputField } from './range_input_field';
const meta = {
title: 'Components/Form Fields/RangeInputField',
component: RangeInputField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
checked: false,
disabled: false,
},
} satisfies Meta<typeof RangeInputField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const Markers: Story = {
args: {
markers: [
{ value: 0, label: 'None' },
{ value: 25, label: 'Some' },
{ value: 50, label: 'Half' },
{ value: 75, label: 'Most' },
{ value: 100, label: 'All' },
],
},
};

View File

@@ -0,0 +1,86 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, useId } from 'react';
import classNames from 'classnames';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './range_input.module.scss';
export type RangeInputProps = Omit<
ComponentPropsWithoutRef<'input'>,
'type' | 'list'
> & {
markers?: { value: number; label: string }[] | number[];
};
interface Props extends RangeInputProps, CommonFieldWrapperProps {}
/**
* A simple form field for single-line text.
*
* Accepts an optional `hint` and can be marked as required
* or optional (by explicitly setting `required={false}`)
*/
export const RangeInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => <RangeInput {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
RangeInputField.displayName = 'RangeInputField';
export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(
({ className, markers, id, ...otherProps }, ref) => {
const markersId = useId();
if (!markers) {
return (
<input
{...otherProps}
type='range'
className={classNames(className, classes.input)}
ref={ref}
/>
);
}
return (
<>
<input
{...otherProps}
type='range'
className={classNames(className, classes.input)}
ref={ref}
list={markersId}
/>
<datalist id={markersId} className={classes.markers}>
{markers.map((marker) => {
const value = typeof marker === 'number' ? marker : marker.value;
return (
<option
key={value}
value={value}
label={typeof marker !== 'number' ? marker.label : undefined}
/>
);
})}
</datalist>
</>
);
},
);
RangeInput.displayName = 'RangeInput';

View File

@@ -51,7 +51,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -19,12 +19,12 @@ interface Props
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => (

View File

@@ -38,7 +38,17 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: { variant: 'error', message: "This field can't be empty" },
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
},
};

View File

@@ -26,14 +26,14 @@ export const TextAreaField = forwardRef<
TextAreaProps & CommonFieldWrapperProps
>(
(
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
{ id, label, hint, required, status, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
className={wrapperClassName}
>

View File

@@ -29,16 +29,16 @@
color: var(--color-text-secondary);
}
&:focus {
outline-color: var(--color-text-brand);
}
&:focus:user-invalid,
&:required:user-invalid,
[data-has-error='true'] & {
outline-color: var(--color-text-error);
}
&:focus {
outline-color: var(--color-text-brand);
}
&:required:user-valid {
outline-color: var(--color-text-success);
}

View File

@@ -40,7 +40,17 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
},
};

View File

@@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
className={wrapperClassName}
>

View File

@@ -45,7 +45,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -14,12 +14,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-end'
>

View File

@@ -107,7 +107,7 @@
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
.buttonsDesktop {
@container (width < #{$button-breakpoint}) {
@@ -132,7 +132,7 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
}
@supports (not (container-type: inline-size)) {
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
@media (min-width: ($button-fallback-breakpoint + 1px)) {
display: none;
}
}

View File

@@ -141,8 +141,11 @@ const InnerNodeModal: FC<{
onChange={handleChange}
label={intl.formatMessage(messages.fieldLabel)}
className={classes.noteInput}
hasError={state === 'error'}
hint={errorText}
status={
state === 'error'
? { variant: 'error', message: errorText }
: undefined
}
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
autoFocus
/>

View File

@@ -1,12 +1,15 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import { inputToHashtag } from '@/mastodon/utils/hashtags';
import {
hasSpecialCharacters,
inputToHashtag,
} from '@/mastodon/utils/hashtags';
import type {
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
@@ -31,6 +34,7 @@ import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } =
@@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => {
],
);
const topicHasSpecialCharacters = useMemo(
() => hasSpecialCharacters(topic),
[topic],
);
return (
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
@@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => {
autoCorrect='off'
spellCheck='false'
maxLength={40}
status={
topicHasSpecialCharacters
? {
variant: 'warning',
message: intl.formatMessage({
id: 'collections.topic_special_chars_hint',
defaultMessage:
'Special characters will be removed when saving',
}),
}
: undefined
}
/>
<Fieldset

View File

@@ -230,7 +230,7 @@ export const Profile: React.FC<{
}
value={displayName}
onChange={handleDisplayNameChange}
hasError={!!errors?.display_name}
status={errors?.display_name ? 'error' : undefined}
id='display_name'
/>
</div>
@@ -252,7 +252,7 @@ export const Profile: React.FC<{
}
value={note}
onChange={handleNoteChange}
hasError={!!errors?.note}
status={errors?.note ? 'error' : undefined}
id='note'
/>
</div>

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Samlede feltet \"{item}\" op.",
"account_edit.field_reorder_modal.handle_label": "Træk feltet \"{item}\"",
"account_edit.field_reorder_modal.title": "Omarrangér felter",
"account_edit.image_edit.add_button": "Tilføj billede",
"account_edit.image_edit.alt_add_button": "Tilføj alt-tekst",
"account_edit.image_edit.alt_edit_button": "Rediger alt-tekst",
"account_edit.image_edit.remove_button": "Fjern billede",
"account_edit.image_edit.replace_button": "Erstat billede",
"account_edit.name_modal.add_title": "Tilføj visningsnavn",
"account_edit.name_modal.edit_title": "Rediger visningsnavn",
"account_edit.profile_tab.button_label": "Tilpas",

View File

@@ -174,7 +174,7 @@
"account_edit.field_edit_modal.name_hint": "z. B. „Meine Website“",
"account_edit.field_edit_modal.name_label": "Beschriftung",
"account_edit.field_edit_modal.url_warning": "Um einen Link hinzuzufügen, füge {protocol} an den Anfang ein.",
"account_edit.field_edit_modal.value_hint": "z. B. „https://example.me“",
"account_edit.field_edit_modal.value_hint": "z. B. „https://beispiel.tld“",
"account_edit.field_edit_modal.value_label": "Inhalt",
"account_edit.field_reorder_modal.drag_cancel": "Das Ziehen wurde abgebrochen und das Feld „{item}“ wurde abgelegt.",
"account_edit.field_reorder_modal.drag_end": "Das Feld „{item}“ wurde abgelegt.",
@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Das Feld „{item}“ wurde ausgewählt.",
"account_edit.field_reorder_modal.handle_label": "Das Feld „{item}“ verschieben",
"account_edit.field_reorder_modal.title": "Felder neu anordnen",
"account_edit.image_edit.add_button": "Bild hinzufügen",
"account_edit.image_edit.alt_add_button": "Bildbeschreibung hinzufügen",
"account_edit.image_edit.alt_edit_button": "Bildbeschreibung bearbeiten",
"account_edit.image_edit.remove_button": "Bild entfernen",
"account_edit.image_edit.replace_button": "Bild ersetzen",
"account_edit.name_modal.add_title": "Anzeigenamen hinzufügen",
"account_edit.name_modal.edit_title": "Anzeigenamen bearbeiten",
"account_edit.profile_tab.button_label": "Anpassen",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Το πεδίο \"{item}\" σηκώθηκε.",
"account_edit.field_reorder_modal.handle_label": "Μετακίνηση πεδίου \"{item}\"",
"account_edit.field_reorder_modal.title": "Αναδιάταξη πεδίων",
"account_edit.image_edit.add_button": "Προσθήκη εικόνας",
"account_edit.image_edit.alt_add_button": "Προσθήκη εναλλακτικού κειμένου",
"account_edit.image_edit.alt_edit_button": "Επεξεργασία εναλλακτικού κειμένου",
"account_edit.image_edit.remove_button": "Αφαίρεση εικόνας",
"account_edit.image_edit.replace_button": "Αντικατάσταση εικόνας",
"account_edit.name_modal.add_title": "Προσθήκη εμφανιζόμενου ονόματος",
"account_edit.name_modal.edit_title": "Επεξεργασία εμφανιζόμενου ονόματος",
"account_edit.profile_tab.button_label": "Προσαρμογή",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"",
"account_edit.field_reorder_modal.title": "Rearrange fields",
"account_edit.image_edit.add_button": "Add image",
"account_edit.image_edit.alt_add_button": "Add alt text",
"account_edit.image_edit.alt_edit_button": "Edit alt text",
"account_edit.image_edit.remove_button": "Remove image",
"account_edit.image_edit.replace_button": "Replace image",
"account_edit.name_modal.add_title": "Add display name",
"account_edit.name_modal.edit_title": "Edit display name",
"account_edit.profile_tab.button_label": "Customise",

View File

@@ -374,6 +374,7 @@
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
"collections.sensitive": "Sensitive",
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
"collections.topic_special_chars_hint": "Special characters will be removed when saving",
"collections.view_collection": "View collection",
"collections.view_other_collections_by_user": "View other collections by this user",
"collections.visibility_public": "Public",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Campo elegido «{item}».",
"account_edit.field_reorder_modal.handle_label": "Arrastrá el campo «{item}»",
"account_edit.field_reorder_modal.title": "Reordená los campos",
"account_edit.image_edit.add_button": "Agregar imagen",
"account_edit.image_edit.alt_add_button": "Agregar texto alternativo",
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
"account_edit.image_edit.remove_button": "Quitar imagen",
"account_edit.image_edit.replace_button": "Reemplazar imagen",
"account_edit.name_modal.add_title": "Agregar nombre a mostrar",
"account_edit.name_modal.edit_title": "Editar nombre a mostrar",
"account_edit.profile_tab.button_label": "Personalizar",
@@ -961,12 +966,14 @@
"notifications_permission_banner.title": "No te pierdas nada",
"onboarding.follows.back": "Volver",
"onboarding.follows.empty": "Desafortunadamente, no se pueden mostrar resultados en este momento. Podés intentar usar la búsqueda o navegar por la página de exploración para encontrar cuentas a las que seguir, o intentarlo de nuevo más tarde.",
"onboarding.follows.next": "Siguiente: Configurá tu perfil",
"onboarding.follows.search": "Buscar",
"onboarding.follows.title": "Para comenzar, empezá a seguir cuentas",
"onboarding.profile.discoverable": "Hacer que mi perfil sea detectable",
"onboarding.profile.discoverable_hint": "Cuando optás por ser detectable en Mastodon, tus mensajes pueden aparecer en los resultados de búsqueda y de tendencia, y tu perfil puede ser sugerido a personas con intereses similares a los tuyos.",
"onboarding.profile.display_name": "Nombre para mostrar",
"onboarding.profile.display_name_hint": "Tu nombre completo o tu pseudónimo…",
"onboarding.profile.finish": "Finalizar",
"onboarding.profile.note": "Biografía",
"onboarding.profile.note_hint": "Podés @mencionar otras cuentas o usar #etiquetas…",
"onboarding.profile.title": "Configuración del perfil",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Recogido el campo «{item}».",
"account_edit.field_reorder_modal.handle_label": "Arrastra el campo «{item}»",
"account_edit.field_reorder_modal.title": "Reorganizar campos",
"account_edit.image_edit.add_button": "Añadir imagen",
"account_edit.image_edit.alt_add_button": "Añadir texto alternativo",
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
"account_edit.image_edit.remove_button": "Eliminar imagen",
"account_edit.image_edit.replace_button": "Reemplazar imagen",
"account_edit.name_modal.add_title": "Añadir nombre para mostrar",
"account_edit.name_modal.edit_title": "Editar nombre para mostrar",
"account_edit.profile_tab.button_label": "Personalizar",

View File

@@ -173,6 +173,8 @@
"account_edit.field_edit_modal.link_emoji_warning": "Recomendamos no usar emojis personalizados combinados con enlaces. Los campos personalizados que contengan ambos solo se mostrarán como texto en vez de un enlace, para evitar confusiones.",
"account_edit.field_edit_modal.name_hint": "Ej. \"Web personal\"",
"account_edit.field_edit_modal.name_label": "Etiqueta",
"account_edit.field_edit_modal.url_warning": "Para añadir un enlace, incluye {protocol} al principio.",
"account_edit.field_edit_modal.value_hint": "Ejemplo: “https://example.me”",
"account_edit.field_edit_modal.value_label": "Valor",
"account_edit.field_reorder_modal.drag_cancel": "El arrastre se ha cancelado. El campo \"{item}\" se ha soltado.",
"account_edit.field_reorder_modal.drag_end": "El campo \"{item}\" se ha soltado.",
@@ -182,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Campo \"{item}\" seleccionado.",
"account_edit.field_reorder_modal.handle_label": "Arrastra el campo \"{item}\"",
"account_edit.field_reorder_modal.title": "Reorganizar campos",
"account_edit.image_edit.add_button": "Añadir imagen",
"account_edit.image_edit.alt_add_button": "Añadir texto alternativo",
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
"account_edit.image_edit.remove_button": "Quitar imagen",
"account_edit.image_edit.replace_button": "Sustituir imagen",
"account_edit.name_modal.add_title": "Añadir nombre para mostrar",
"account_edit.name_modal.edit_title": "Editar nombre para mostrar",
"account_edit.profile_tab.button_label": "Personalizar",
@@ -337,14 +344,14 @@
"collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Limitado a 100 caracteres",
"collections.detail.accept_inclusion": "Aceptar",
"collections.detail.accept_inclusion": "De acuerdo",
"collections.detail.accounts_heading": "Cuentas",
"collections.detail.author_added_you": "{author} te añadió a esta colección",
"collections.detail.curated_by_author": "Seleccionado por {author}",
"collections.detail.curated_by_you": "Seleccionado por ti",
"collections.detail.loading": "Cargando colección…",
"collections.detail.other_accounts_in_collection": "Otros en esta colección:",
"collections.detail.revoke_inclusion": "Eliminar",
"collections.detail.revoke_inclusion": "Sácame de aquí",
"collections.detail.sensitive_note": "Esta colección contiene cuentas y contenido que puede ser sensible para algunos usuarios.",
"collections.detail.share": "Compartir esta colección",
"collections.edit_details": "Editar detalles",
@@ -360,9 +367,9 @@
"collections.old_last_post_note": "Última publicación hace más de una semana",
"collections.remove_account": "Quitar esta cuenta",
"collections.report_collection": "Informar de esta colección",
"collections.revoke_collection_inclusion": "Eliminarme de esta colección",
"collections.revoke_inclusion.confirmation": "Ha sido eliminado de\"{collection}\"",
"collections.revoke_inclusion.error": "Se ha producido un error. Inténtelo de nuevo más tarde.",
"collections.revoke_collection_inclusion": "Sácame de esta colección",
"collections.revoke_inclusion.confirmation": "Has salido de la \"{collection}\"",
"collections.revoke_inclusion.error": "Se ha producido un error, inténtalo de nuevo más tarde.",
"collections.search_accounts_label": "Buscar cuentas para añadir…",
"collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas",
"collections.sensitive": "Sensible",
@@ -486,9 +493,9 @@
"confirmations.remove_from_followers.confirm": "Eliminar seguidor",
"confirmations.remove_from_followers.message": "{name} dejará de seguirte. ¿Estás seguro de que quieres continuar?",
"confirmations.remove_from_followers.title": "¿Eliminar seguidor?",
"confirmations.revoke_collection_inclusion.confirm": "Eliminar",
"confirmations.revoke_collection_inclusion.confirm": "Sácame",
"confirmations.revoke_collection_inclusion.message": "Esta acción es permanente, y el curador no podrá volver a añadirle a la colección más adelante.",
"confirmations.revoke_collection_inclusion.title": "¿Eliminarse de esta colección?",
"confirmations.revoke_collection_inclusion.title": "¿Salirse de esta colección?",
"confirmations.revoke_quote.confirm": "Eliminar publicación",
"confirmations.revoke_quote.message": "Esta acción no tiene vuelta atrás.",
"confirmations.revoke_quote.title": "¿Eliminar la publicación?",
@@ -959,12 +966,14 @@
"notifications_permission_banner.title": "Nunca te pierdas nada",
"onboarding.follows.back": "Atrás",
"onboarding.follows.empty": "Desafortunadamente, no se pueden mostrar resultados en este momento. Puedes intentar usar la búsqueda o navegar por la página de exploración para encontrar personas a las que seguir, o inténtalo de nuevo más tarde.",
"onboarding.follows.next": "Siguiente: Configura tu perfil",
"onboarding.follows.search": "Buscar",
"onboarding.follows.title": "Sigue personas para comenzar",
"onboarding.profile.discoverable": "Hacer que mi perfil aparezca en búsquedas",
"onboarding.profile.discoverable_hint": "Cuando permites que tu perfil aparezca en búsquedas en Mastodon, tus publicaciones podrán aparecer en los resultados de búsqueda y en tendencias, y tu perfil podrá recomendarse a gente con intereses similares a los tuyos.",
"onboarding.profile.display_name": "Nombre para mostrar",
"onboarding.profile.display_name_hint": "Tu nombre completo o tu apodo…",
"onboarding.profile.finish": "Terminar",
"onboarding.profile.note": "Biografía",
"onboarding.profile.note_hint": "Puedes @mencionar a otras personas o #etiquetas…",
"onboarding.profile.title": "Configuración del perfil",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Valittu kenttä ”{item}”.",
"account_edit.field_reorder_modal.handle_label": "Siirrä kenttää ”{item}”",
"account_edit.field_reorder_modal.title": "Järjestele kenttiä",
"account_edit.image_edit.add_button": "Lisää kuva",
"account_edit.image_edit.alt_add_button": "Lisää tekstivastine",
"account_edit.image_edit.alt_edit_button": "Muokkaa tekstivastinetta",
"account_edit.image_edit.remove_button": "Poista kuva",
"account_edit.image_edit.replace_button": "Korvaa kuva",
"account_edit.name_modal.add_title": "Lisää näyttönimi",
"account_edit.name_modal.edit_title": "Muokkaa näyttönimeä",
"account_edit.profile_tab.button_label": "Mukauta",
@@ -225,8 +230,8 @@
"alert.rate_limited.title": "Pyyntömäärää rajoitettu",
"alert.unexpected.message": "Tapahtui odottamaton virhe.",
"alert.unexpected.title": "Hups!",
"alt_text_badge.title": "Vaihtoehtoinen teksti",
"alt_text_modal.add_alt_text": "Lisää vaihtoehtoinen teksti",
"alt_text_badge.title": "Tekstivastine",
"alt_text_modal.add_alt_text": "Lisää tekstivastine",
"alt_text_modal.add_text_from_image": "Lisää teksti kuvasta",
"alt_text_modal.cancel": "Peruuta",
"alt_text_modal.change_thumbnail": "Vaihda pikkukuva",
@@ -468,10 +473,10 @@
"confirmations.logout.confirm": "Kirjaudu ulos",
"confirmations.logout.message": "Haluatko varmasti kirjautua ulos?",
"confirmations.logout.title": "Kirjaudutaanko ulos?",
"confirmations.missing_alt_text.confirm": "Lisää vaihtoehtoinen teksti",
"confirmations.missing_alt_text.message": "Julkaisussasi on mediaa ilman vaihtoehtoista tekstiä. Kuvausten lisääminen auttaa tekemään sisällöstäsi saavutettavamman useammille ihmisille.",
"confirmations.missing_alt_text.confirm": "Lisää tekstivastine",
"confirmations.missing_alt_text.message": "Julkaisussasi on mediaa ilman tekstivastinetta. Kuvausten lisääminen auttaa tekemään sisällöstäsi saavutettavamman useammille ihmisille.",
"confirmations.missing_alt_text.secondary": "Julkaise silti",
"confirmations.missing_alt_text.title": "Lisätäänkö vaihtoehtoinen teksti?",
"confirmations.missing_alt_text.title": "Lisätäänkö tekstivastine?",
"confirmations.mute.confirm": "Mykistä",
"confirmations.private_quote_notify.cancel": "Takaisin muokkaukseen",
"confirmations.private_quote_notify.confirm": "Julkaise",
@@ -706,7 +711,7 @@
"ignore_notifications_modal.not_following_title": "Sivuutetaanko ilmoitukset käyttäjiltä, joita et seuraa?",
"ignore_notifications_modal.private_mentions_title": "Sivuutetaanko ilmoitukset pyytämättömistä yksityismaininnoista?",
"info_button.label": "Ohje",
"info_button.what_is_alt_text": "<h1>Mikä vaihtoehtoinen teksti on?</h1> <p>Vaihtoehtoinen teksti tarjoaa kuvauksen kuvista ihmisille, joilla on näkövamma tai matalan kaistanleveyden yhteys tai jotka kaipaavat lisäkontekstia.</p> <p>Voit parantaa saavutettavuutta ja ymmärrettävyyttä kaikkien näkökulmasta kirjoittamalla selkeän, tiiviin ja objektiivisen vaihtoehtoisen tekstin.</p> <ul> <li>Ota mukaan tärkeät elementit</li> <li>Tiivistä kuvissa oleva teksti</li> <li>Käytä tavallisia lauserakenteita</li> <li>Vältä turhaa tietoa</li> <li>Keskity trendeihin ja keskeisiin tuloksiin monimutkaisissa visuaalisissa esityksissä (kuten kaavioissa tai kartoissa)</li> </ul>",
"info_button.what_is_alt_text": "<h1>Mikä tekstivastine on?</h1> <p>Tekstivastine tarjoaa kuvauksen kuvista ihmisille, joilla on näkövamma tai matalan kaistanleveyden yhteys tai jotka kaipaavat lisäkontekstia.</p> <p>Voit parantaa saavutettavuutta ja ymmärrettävyyttä kaikkien näkökulmasta kirjoittamalla selkeän, tiiviin ja objektiivisen tekstivastineen.</p> <ul> <li>Ota mukaan tärkeät elementit</li> <li>Tiivistä kuvissa oleva teksti</li> <li>Käytä tavallisia lauserakenteita</li> <li>Vältä turhaa tietoa</li> <li>Keskity trendeihin ja keskeisiin tuloksiin monimutkaisissa visuaalisissa esityksissä (kuten kaavioissa tai kartoissa)</li> </ul>",
"interaction_modal.action": "Jotta voit olla vuorovaikutuksessa käyttäjän {name} julkaisun kanssa, sinun on kirjauduttava sisään tilillesi käyttämälläsi Mastodon-palvelimella.",
"interaction_modal.go": "Siirry",
"interaction_modal.no_account_yet": "Eikö sinulla ole vielä tiliä?",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Champ « {item} » sélectionné.",
"account_edit.field_reorder_modal.handle_label": "Faites glisser le champ « {item} »",
"account_edit.field_reorder_modal.title": "Réorganiser les champs",
"account_edit.image_edit.add_button": "Ajouter une image",
"account_edit.image_edit.alt_add_button": "Ajouter un texte alternatif",
"account_edit.image_edit.alt_edit_button": "Modifier le texte alternatif",
"account_edit.image_edit.remove_button": "Supprimer limage",
"account_edit.image_edit.replace_button": "Remplacer l'image",
"account_edit.name_modal.add_title": "Ajouter un nom public",
"account_edit.name_modal.edit_title": "Modifier le nom public",
"account_edit.profile_tab.button_label": "Personnaliser",
@@ -961,12 +966,14 @@
"notifications_permission_banner.title": "Ne rien rater",
"onboarding.follows.back": "Retour",
"onboarding.follows.empty": "Malheureusement, aucun résultat ne peut être affiché pour le moment. Vous pouvez essayer de rechercher ou de parcourir la page \"Explorer\" pour trouver des personnes à suivre, ou réessayer plus tard.",
"onboarding.follows.next": "Suivant : configurer votre profil",
"onboarding.follows.search": "Recherche",
"onboarding.follows.title": "Suivre des personnes pour commencer",
"onboarding.profile.discoverable": "Permettre de découvrir mon profil",
"onboarding.profile.discoverable_hint": "Lorsque vous acceptez d'être découvert sur Mastodon, vos messages peuvent apparaître dans les résultats de recherche et les tendances, et votre profil peut être suggéré à des personnes ayant des intérêts similaires aux vôtres.",
"onboarding.profile.display_name": "Nom affiché",
"onboarding.profile.display_name_hint": "Votre nom complet ou votre nom rigolo…",
"onboarding.profile.finish": "Terminer",
"onboarding.profile.note": "Bio",
"onboarding.profile.note_hint": "Vous pouvez @mentionner d'autres personnes ou #hashtags…",
"onboarding.profile.title": "Configuration du profil",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Champ « {item} » sélectionné.",
"account_edit.field_reorder_modal.handle_label": "Faites glisser le champ « {item} »",
"account_edit.field_reorder_modal.title": "Réorganiser les champs",
"account_edit.image_edit.add_button": "Ajouter une image",
"account_edit.image_edit.alt_add_button": "Ajouter un texte alternatif",
"account_edit.image_edit.alt_edit_button": "Modifier le texte alternatif",
"account_edit.image_edit.remove_button": "Supprimer limage",
"account_edit.image_edit.replace_button": "Remplacer l'image",
"account_edit.name_modal.add_title": "Ajouter un nom public",
"account_edit.name_modal.edit_title": "Modifier le nom public",
"account_edit.profile_tab.button_label": "Personnaliser",
@@ -961,12 +966,14 @@
"notifications_permission_banner.title": "Toujours au courant",
"onboarding.follows.back": "Retour",
"onboarding.follows.empty": "Malheureusement, aucun résultat ne peut être affiché pour le moment. Vous pouvez essayer d'utiliser la recherche ou parcourir la page de découverte pour trouver des personnes à suivre, ou réessayez plus tard.",
"onboarding.follows.next": "Suivant : configurer votre profil",
"onboarding.follows.search": "Recherche",
"onboarding.follows.title": "Suivre des personnes pour commencer",
"onboarding.profile.discoverable": "Permettre de découvrir mon profil",
"onboarding.profile.discoverable_hint": "Lorsque vous acceptez d'être découvert sur Mastodon, vos messages peuvent apparaître dans les résultats de recherche et les tendances, et votre profil peut être suggéré à des personnes ayant des intérêts similaires aux vôtres.",
"onboarding.profile.display_name": "Nom affiché",
"onboarding.profile.display_name_hint": "Votre nom complet ou votre nom rigolo…",
"onboarding.profile.finish": "Terminer",
"onboarding.profile.note": "Biographie",
"onboarding.profile.note_hint": "Vous pouvez @mentionner d'autres personnes ou #hashtags…",
"onboarding.profile.title": "Configuration du profil",

View File

@@ -173,6 +173,8 @@
"account_edit.field_edit_modal.link_emoji_warning": "Molaimid gan emoji saincheaptha a úsáid i gcomhar le Urlanna. Taispeánfar réimsí saincheaptha ina bhfuil an dá cheann mar théacs amháin seachas mar nasc, chun mearbhall úsáideoirí a sheachaint.",
"account_edit.field_edit_modal.name_hint": "M.sh. “Suíomh Gréasáin pearsanta”",
"account_edit.field_edit_modal.name_label": "Lipéad",
"account_edit.field_edit_modal.url_warning": "Chun nasc a chur leis, cuir {protocol} ag an tús le do thoil.",
"account_edit.field_edit_modal.value_hint": "M.sh. “https://example.me”",
"account_edit.field_edit_modal.value_label": "Luach",
"account_edit.field_reorder_modal.drag_cancel": "Cuireadh an tarraingt ar ceal. Baineadh an réimse \"{item}\".",
"account_edit.field_reorder_modal.drag_end": "Baineadh an réimse \"{item}\".",
@@ -182,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Réimse \"{item}\" bailithe.",
"account_edit.field_reorder_modal.handle_label": "Tarraing réimse \"{item}\"",
"account_edit.field_reorder_modal.title": "Athshocraigh réimsí",
"account_edit.image_edit.add_button": "Cuir íomhá leis",
"account_edit.image_edit.alt_add_button": "Cuir téacs alt leis",
"account_edit.image_edit.alt_edit_button": "Cuir téacs alt in eagar",
"account_edit.image_edit.remove_button": "Bain íomhá",
"account_edit.image_edit.replace_button": "Athsholáthair íomhá",
"account_edit.name_modal.add_title": "Cuir ainm taispeána leis",
"account_edit.name_modal.edit_title": "Cuir ainm taispeána in eagar",
"account_edit.profile_tab.button_label": "Saincheap",
@@ -959,12 +966,14 @@
"notifications_permission_banner.title": "Ná caill aon rud go deo",
"onboarding.follows.back": "Ar ais",
"onboarding.follows.empty": "Ar an drochuair, ní féidir aon torthaí a thaispeáint faoi láthair. Is féidir leat triail a bhaint as cuardach nó brabhsáil ar an leathanach taiscéalaíochta chun teacht ar dhaoine le leanúint, nó bain triail eile as níos déanaí.",
"onboarding.follows.next": "Ar Aghaidh: Socraigh do phróifíl",
"onboarding.follows.search": "Cuardach",
"onboarding.follows.title": "Lean daoine le tosú",
"onboarding.profile.discoverable": "Déan mo phróifíl a fháil amach",
"onboarding.profile.discoverable_hint": "Nuair a roghnaíonn tú infhionnachtana ar Mastodon, dfhéadfadh do phoist a bheith le feiceáil i dtorthaí cuardaigh agus treochtaí, agus dfhéadfaí do phróifíl a mholadh do dhaoine a bhfuil na leasanna céanna acu leat.",
"onboarding.profile.display_name": "Ainm taispeána",
"onboarding.profile.display_name_hint": "Dainm iomlán nó dainm spraíúil…",
"onboarding.profile.finish": "Críochnaigh",
"onboarding.profile.note": "Bith",
"onboarding.profile.note_hint": "Is féidir leat @ daoine eile a lua nó #hashtags…",
"onboarding.profile.title": "Socrú próifíle",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Seleccionado o campo \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Arrastra o campo \"{item}\"",
"account_edit.field_reorder_modal.title": "Ordear campos",
"account_edit.image_edit.add_button": "Engadir imaxe",
"account_edit.image_edit.alt_add_button": "Engadir descrición",
"account_edit.image_edit.alt_edit_button": "Editar descrición",
"account_edit.image_edit.remove_button": "Retirar a imaxe",
"account_edit.image_edit.replace_button": "Substituír a imaxe",
"account_edit.name_modal.add_title": "Engadir nome público",
"account_edit.name_modal.edit_title": "Editar o nome público",
"account_edit.profile_tab.button_label": "Personalizar",

View File

@@ -173,6 +173,8 @@
"account_edit.field_edit_modal.link_emoji_warning": "אנו ממליצים נגד שימוש באמוג'י ייחודיים ביחד עם URL. שדות מיוחדים שמכילים את שניהם יופיעו כמלל בלבד ולא כקישור, כדי למנוע בלבול משתמשים.",
"account_edit.field_edit_modal.name_hint": "למשל \"אתר אישי\"",
"account_edit.field_edit_modal.name_label": "תווית",
"account_edit.field_edit_modal.url_warning": "כדי להוסיף קישור, אנא הכלילו {protocol} בהתחלה.",
"account_edit.field_edit_modal.value_hint": "למשל “https://example.me”",
"account_edit.field_edit_modal.value_label": "ערך",
"account_edit.field_reorder_modal.drag_cancel": "הגרירה בוטלה. השדה \"{item}\" נעזב.",
"account_edit.field_reorder_modal.drag_end": "השדה \"{item}\" נעזב.",
@@ -182,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "השדה \"{item}\" נבחר.",
"account_edit.field_reorder_modal.handle_label": "הזזת השדה \"{item}\"",
"account_edit.field_reorder_modal.title": "סידור שדות",
"account_edit.image_edit.add_button": "הוספת תמונה",
"account_edit.image_edit.alt_add_button": "הוספת מלל חלופי",
"account_edit.image_edit.alt_edit_button": "עריכת מלל חלופי",
"account_edit.image_edit.remove_button": "הסרת תמונה",
"account_edit.image_edit.replace_button": "החלפת תמונה",
"account_edit.name_modal.add_title": "הוספת שם תצוגה",
"account_edit.name_modal.edit_title": "עריכת שם תצוגה",
"account_edit.profile_tab.button_label": "התאמה אישית",
@@ -959,12 +966,14 @@
"notifications_permission_banner.title": "לעולם אל תחמיץ דבר",
"onboarding.follows.back": "בחזרה",
"onboarding.follows.empty": "למצער, תוצאות לחיפושך אינן בנמצא. ניתן להשתמש בחיפוש או בדף החקירות לשם מציאת אנשים ולעקבם. אפשר גם לנסות שוב אחר כך.",
"onboarding.follows.next": "להמשיך ליצירת הפרופיל שלך",
"onboarding.follows.search": "חיפוש",
"onboarding.follows.title": "כדי להתחיל, יש לעקוב אחרי אנשים",
"onboarding.profile.discoverable": "כלול את הפרופיל שלי בעמודת התגליות",
"onboarding.profile.discoverable_hint": "כשתבחרו להכלל ב\"תגליות\" על מסטודון, ההודעות שלכם עשויות להופיע בתוצאות חיפוש ועמודות \"נושאים חמים\", והפרופיל יוצע לאחרים עם תחומי עניין משותפים לכם.",
"onboarding.profile.display_name": "שם להצגה",
"onboarding.profile.display_name_hint": "שמך המלא או כינוי הכיף שלך…",
"onboarding.profile.finish": "סיום",
"onboarding.profile.note": "אודות",
"onboarding.profile.note_hint": "ניתן @לאזכר משתמשים אחרים או #תגיות…",
"onboarding.profile.title": "הגדרת פרופיל",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "A(z) „{item}” mező áthelyezéshez felvéve.",
"account_edit.field_reorder_modal.handle_label": "A(z) „{item}” mező húzása",
"account_edit.field_reorder_modal.title": "Mezők átrendezése",
"account_edit.image_edit.add_button": "Kép hozzáadása",
"account_edit.image_edit.alt_add_button": "Helyettesítő szöveg hozzáadása",
"account_edit.image_edit.alt_edit_button": "Helyettesítő szöveg szerkesztése",
"account_edit.image_edit.remove_button": "Kép eltávolítása",
"account_edit.image_edit.replace_button": "Kép cseréje",
"account_edit.name_modal.add_title": "Megjelenítendő név hozzáadása",
"account_edit.name_modal.edit_title": "Megjelenítendő név szerkesztése",
"account_edit.profile_tab.button_label": "Testreszabás",
@@ -961,12 +966,14 @@
"notifications_permission_banner.title": "Soha ne mulassz el semmit",
"onboarding.follows.back": "Vissza",
"onboarding.follows.empty": "Sajnos jelenleg nem jeleníthető meg eredmény. Kipróbálhatod a keresést vagy böngészheted a felfedező oldalon a követni kívánt személyeket, vagy próbáld meg később.",
"onboarding.follows.next": "Következik: A profil beállítása",
"onboarding.follows.search": "Keresés",
"onboarding.follows.title": "A kezdéshez kezdj el embereket követni",
"onboarding.profile.discoverable": "Saját profil beállítása felfedezhetőként",
"onboarding.profile.discoverable_hint": "A Mastodonon a felfedezhetőség választása esetén a saját bejegyzéseid megjelenhetnek a keresési eredmények és a felkapott tartalmak között, valamint a profilod a hozzád hasonló érdeklődési körrel rendelkező embereknél is ajánlásra kerülhet.",
"onboarding.profile.display_name": "Megjelenített név",
"onboarding.profile.display_name_hint": "Teljes neved vagy vicces neved…",
"onboarding.profile.finish": "Befejezés",
"onboarding.profile.note": "Bemutatkozás",
"onboarding.profile.note_hint": "Megemlíthetsz @másokat vagy #hashtag-eket…",
"onboarding.profile.title": "Profilbeállítás",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Náði reitnum \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Dragðu reitinn \"{item}\"",
"account_edit.field_reorder_modal.title": "Endurraða gagnasviðum",
"account_edit.image_edit.add_button": "Bæta við mynd",
"account_edit.image_edit.alt_add_button": "Bæta við hjálpartexta",
"account_edit.image_edit.alt_edit_button": "Breyta hjálpartexta",
"account_edit.image_edit.remove_button": "Fjarlægja mynd",
"account_edit.image_edit.replace_button": "Skipta um mynd",
"account_edit.name_modal.add_title": "Bættu við birtingarnafni",
"account_edit.name_modal.edit_title": "Breyta birtingarnafni",
"account_edit.profile_tab.button_label": "Sérsníða",
@@ -961,12 +966,14 @@
"notifications_permission_banner.title": "Aldrei missa af neinu",
"onboarding.follows.back": "Til baka",
"onboarding.follows.empty": "Því miður er ekki hægt að birta neinar niðurstöður í augnablikinu. Þú getur reynt að nota leitina eða skoðað könnunarsíðuna til að finna fólk til að fylgjast með, nú eða prófað aftur síðar.",
"onboarding.follows.next": "Næsta: Settu upp notandasniðið þitt",
"onboarding.follows.search": "Leita",
"onboarding.follows.title": "Þú ættir að fylgjast með fólki til að komast í gang",
"onboarding.profile.discoverable": "Gera notandasniðið mitt uppgötvanlegt",
"onboarding.profile.discoverable_hint": "Þegar þú velur að hægt sé að uppgötva þig á Mastodon, munu færslurnar þínar birtast í leitarniðurstöðum og vinsældalistum, auk þess sem stungið verður upp á notandasniðinu þínu við fólk sem er með svipuð áhugamál og þú.",
"onboarding.profile.display_name": "Birtingarnafn",
"onboarding.profile.display_name_hint": "Fullt nafn þitt eða eitthvað til gamans…",
"onboarding.profile.finish": "Ljúka",
"onboarding.profile.note": "Æviágrip",
"onboarding.profile.note_hint": "Þú getur @minnst á annað fólk eða #myllumerki…",
"onboarding.profile.title": "Uppsetning notandasniðs",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Campo \"{item}\" selezionato.",
"account_edit.field_reorder_modal.handle_label": "Trascina il campo \"{item}\"",
"account_edit.field_reorder_modal.title": "Riorganizza i campi",
"account_edit.image_edit.add_button": "Aggiungi un'immagine",
"account_edit.image_edit.alt_add_button": "Aggiungi il testo alternativo",
"account_edit.image_edit.alt_edit_button": "Modifica il testo alternativo",
"account_edit.image_edit.remove_button": "Rimuovi l'immagine",
"account_edit.image_edit.replace_button": "Sostituisci l'immagine",
"account_edit.name_modal.add_title": "Aggiungi il nome mostrato",
"account_edit.name_modal.edit_title": "Modifica il nome mostrato",
"account_edit.profile_tab.button_label": "Personalizza",

View File

@@ -173,6 +173,7 @@
"account_edit.field_edit_modal.link_emoji_warning": "Não recomendamos o uso de emojis personalizados em combinação com URLs. Campos personalizados que contenham ambos serão exibidos apenas como texto, em vez de como hiperligação, para evitar confusão aos utilizadores.",
"account_edit.field_edit_modal.name_hint": "Ex.: \"Site pessoal\"",
"account_edit.field_edit_modal.name_label": "Rótulo",
"account_edit.field_edit_modal.value_hint": "Ex.: \"https://exemplo.eu\"",
"account_edit.field_edit_modal.value_label": "Valor",
"account_edit.field_reorder_modal.drag_cancel": "O arrastamento foi cancelado. O campo \"{item}\" foi largado.",
"account_edit.field_reorder_modal.drag_end": "O campo \"{item}\" foi largado.",
@@ -182,6 +183,11 @@
"account_edit.field_reorder_modal.drag_start": "Apanhou o campo \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Arrastar o campo \"{item}\"",
"account_edit.field_reorder_modal.title": "Reordenar campos",
"account_edit.image_edit.add_button": "Adicionar imagem",
"account_edit.image_edit.alt_add_button": "Adicionar texto alternativo",
"account_edit.image_edit.alt_edit_button": "Editar texto alternativo",
"account_edit.image_edit.remove_button": "Remover imagem",
"account_edit.image_edit.replace_button": "Substituir imagem",
"account_edit.name_modal.add_title": "Adicionar nome a mostrar",
"account_edit.name_modal.edit_title": "Editar o nome a mostrar",
"account_edit.profile_tab.button_label": "Personalizar",
@@ -958,12 +964,14 @@
"notifications_permission_banner.title": "Nunca percas nada",
"onboarding.follows.back": "Voltar",
"onboarding.follows.empty": "Infelizmente não é possível mostrar resultados neste momento. Podes tentar pesquisar ou navegar na página \"Explorar\" para encontrares pessoas para seguires ou tentar novamente mais tarde.",
"onboarding.follows.next": "A seguir: configure o seu perfil",
"onboarding.follows.search": "Pesquisar",
"onboarding.follows.title": "Segue pessoas para começar",
"onboarding.profile.discoverable": "Permitir que o meu perfil seja descoberto",
"onboarding.profile.discoverable_hint": "Quando opta pela possibilidade de ser descoberto no Mastodon, as suas mensagens podem aparecer nos resultados de pesquisa e nos destaques, e o seu perfil pode ser sugerido a pessoas com interesses semelhantes aos seus.",
"onboarding.profile.display_name": "Nome a apresentar",
"onboarding.profile.display_name_hint": "O teu nome completo ou o teu nome divertido…",
"onboarding.profile.finish": "Terminar",
"onboarding.profile.note": "Biografia",
"onboarding.profile.note_hint": "Podes @mencionar outras pessoas e usar #etiquetas…",
"onboarding.profile.title": "Configuração do perfil",

View File

@@ -183,6 +183,11 @@
"account_edit.field_reorder_modal.drag_start": "U mor fusha “{item}”.",
"account_edit.field_reorder_modal.handle_label": "Tërhiqni fushën “{item}”",
"account_edit.field_reorder_modal.title": "Risistemoni fusha",
"account_edit.image_edit.add_button": "Shtoni figurë",
"account_edit.image_edit.alt_add_button": "Shtoni tekst alternativ",
"account_edit.image_edit.alt_edit_button": "Përpunoni tekst alternativ",
"account_edit.image_edit.remove_button": "Hiqe figurën",
"account_edit.image_edit.replace_button": "Zëvendësoje figurën",
"account_edit.name_modal.add_title": "Shtoni emër në ekran",
"account_edit.name_modal.edit_title": "Përpunoni emër në ekran",
"account_edit.profile_tab.button_label": "Përshtateni",
@@ -955,12 +960,14 @@
"notifications_permission_banner.title": "Mos tju shpëtojë gjë",
"onboarding.follows.back": "Mbrapsht",
"onboarding.follows.empty": "Mjerisht, smund të shfaqen përfundime tani. Mund të provoni të përdorni kërkimin, ose të shfletoni faqen e eksplorimit, që të gjeni persona për ndjekje, ose të riprovoni më vonë.",
"onboarding.follows.next": "Pasuesi: Ujdisni profilin tuaj",
"onboarding.follows.search": "Kërkoni",
"onboarding.follows.title": "Që tia filloni, ndiqni persona",
"onboarding.profile.discoverable": "Bëje profilin tim të zbulueshëm",
"onboarding.profile.discoverable_hint": "Kur zgjidhni të jeni i zbulueshëm në Mastodon, postimet tuaja mund të shfaqen në përfundime kërkimesh dhe gjëra në modë dhe profili juaj mund tu sugjerohet njerëzve me interesa të ngjashme me ju.",
"onboarding.profile.display_name": "Emër në ekran",
"onboarding.profile.display_name_hint": "Emri juaj i plotë, ose çtë doni…",
"onboarding.profile.finish": "Përfundoje",
"onboarding.profile.note": "Jetëshkrim",
"onboarding.profile.note_hint": "Mund të @përmendni persona të tjerë, ose #hashtagë…",
"onboarding.profile.title": "Udjisje profili",

View File

@@ -167,6 +167,11 @@
"account_edit.field_edit_modal.name_label": "Etikett",
"account_edit.field_edit_modal.url_warning": "För att lägga till en länk, vänligen inkludera {protocol} i början.",
"account_edit.field_edit_modal.value_hint": "T.ex. \"https://example.me”",
"account_edit.image_edit.add_button": "Lägg till bild",
"account_edit.image_edit.alt_add_button": "Lägg till alternativtext",
"account_edit.image_edit.alt_edit_button": "Redigera alternativtext",
"account_edit.image_edit.remove_button": "Ta bort bild",
"account_edit.image_edit.replace_button": "Ersätt bild",
"account_edit.profile_tab.button_label": "Anpassa",
"account_note.placeholder": "Klicka för att lägga till anteckning",
"admin.dashboard.daily_retention": "Användarlojalitet per dag efter registrering",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "\"{item}\" alanı seçildi.",
"account_edit.field_reorder_modal.handle_label": "\"{item}\" alanını sürükle",
"account_edit.field_reorder_modal.title": "Alanları yeniden düzenle",
"account_edit.image_edit.add_button": "Görsel ekle",
"account_edit.image_edit.alt_add_button": "Alternatif metin ekle",
"account_edit.image_edit.alt_edit_button": "Alternatif metni düzenle",
"account_edit.image_edit.remove_button": "Görseli kaldır",
"account_edit.image_edit.replace_button": "Görseli değiştir",
"account_edit.name_modal.add_title": "Görünen ad ekle",
"account_edit.name_modal.edit_title": "Görünen adı düzenle",
"account_edit.profile_tab.button_label": "Özelleştir",
@@ -961,12 +966,14 @@
"notifications_permission_banner.title": "Hiçbir şeyi kaçırmayın",
"onboarding.follows.back": "Geri",
"onboarding.follows.empty": "Maalesef şu an bir sonuç gösterilemiyor. Takip edilecek kişileri bulmak için arama veya keşfet sayfasına gözatmayı kullanabilirsiniz veya daha sonra tekrar deneyin.",
"onboarding.follows.next": "Sonraki: Profilinizi ayarlayın",
"onboarding.follows.search": "Ara",
"onboarding.follows.title": "Başlamak için insanları takip edin",
"onboarding.profile.discoverable": "Profilimi keşfedilebilir yap",
"onboarding.profile.discoverable_hint": "Mastodon'da keşfedilebilirliği etkinleştirdiğinizde, gönderileriniz arama sonuçlarında ve trendlerde görünebilir aynı zamanda profiliniz sizinle benzer ilgi alanlarına sahip kişilere önerilebilir.",
"onboarding.profile.display_name": "Görünen isim",
"onboarding.profile.display_name_hint": "Tam adınız veya kullanıcı adınız…",
"onboarding.profile.finish": "Tamamla",
"onboarding.profile.note": "Kişisel bilgiler",
"onboarding.profile.note_hint": "Diğer insanlara @değinebilir veya #etiketler kullanabilirsiniz…",
"onboarding.profile.title": "Profilini ayarla",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "Đã chọn trường \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Kéo trường \"{item}\"",
"account_edit.field_reorder_modal.title": "Sắp xếp lại trường",
"account_edit.image_edit.add_button": "Thêm ảnh",
"account_edit.image_edit.alt_add_button": "Thêm văn bản thay thế",
"account_edit.image_edit.alt_edit_button": "Sửa văn bản thay thế",
"account_edit.image_edit.remove_button": "Gỡ ảnh",
"account_edit.image_edit.replace_button": "Thay thế ảnh",
"account_edit.name_modal.add_title": "Thêm tên gọi",
"account_edit.name_modal.edit_title": "Sửa tên gọi",
"account_edit.profile_tab.button_label": "Tùy chỉnh",

View File

@@ -173,6 +173,8 @@
"account_edit.field_edit_modal.link_emoji_warning": "我们建议不要同时使用自定义表情和网址。同时包含两者的自定义字段将会显示为纯文本而不是链接形式,以避免用户混淆。",
"account_edit.field_edit_modal.name_hint": "例如:“个人网站”",
"account_edit.field_edit_modal.name_label": "标签",
"account_edit.field_edit_modal.url_warning": "要添加链接,请在开头加上 {protocol}。",
"account_edit.field_edit_modal.value_hint": "例如“https://example.me”",
"account_edit.field_edit_modal.value_label": "值",
"account_edit.field_reorder_modal.drag_cancel": "拖拽已终止。字段“{item}”已被丢弃。",
"account_edit.field_reorder_modal.drag_end": "字段“{item}”已被丢弃。",
@@ -182,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "已选中字段“{item}”。",
"account_edit.field_reorder_modal.handle_label": "拖拽字段“{item}”",
"account_edit.field_reorder_modal.title": "重新排列字段",
"account_edit.image_edit.add_button": "添加图片",
"account_edit.image_edit.alt_add_button": "添加替代文本",
"account_edit.image_edit.alt_edit_button": "编辑替代文本",
"account_edit.image_edit.remove_button": "移除图片",
"account_edit.image_edit.replace_button": "替换图片",
"account_edit.name_modal.add_title": "添加显示名称",
"account_edit.name_modal.edit_title": "编辑显示名称",
"account_edit.profile_tab.button_label": "自定义",
@@ -959,12 +966,14 @@
"notifications_permission_banner.title": "精彩不容错过",
"onboarding.follows.back": "返回",
"onboarding.follows.empty": "很抱歉,现在无法显示任何结果。你可以尝试使用搜索或浏览探索页面来查找要关注的人,或稍后再试。",
"onboarding.follows.next": "下一步:设置你的个人资料",
"onboarding.follows.search": "搜索",
"onboarding.follows.title": "关注用户,玩转 Mastodon",
"onboarding.profile.discoverable": "让我的账号可被他人发现",
"onboarding.profile.discoverable_hint": "当你在 Mastodon 上启用发现功能时,你的嘟文可能会出现在搜索结果与热门中,你的账号可能会被推荐给与你兴趣相似的人。",
"onboarding.profile.display_name": "昵称",
"onboarding.profile.display_name_hint": "你的全名或昵称…",
"onboarding.profile.finish": "完成",
"onboarding.profile.note": "简介",
"onboarding.profile.note_hint": "你可以提及 @其他人 或使用 #话题…",
"onboarding.profile.title": "设置个人资料",

View File

@@ -184,6 +184,11 @@
"account_edit.field_reorder_modal.drag_start": "已選取欄位「{item}」。",
"account_edit.field_reorder_modal.handle_label": "拖放欄位「{item}」",
"account_edit.field_reorder_modal.title": "重新整理欄位",
"account_edit.image_edit.add_button": "新增圖片",
"account_edit.image_edit.alt_add_button": "新增 ALT 說明文字",
"account_edit.image_edit.alt_edit_button": "編輯 ALT 說明文字",
"account_edit.image_edit.remove_button": "移除圖片",
"account_edit.image_edit.replace_button": "替換圖片",
"account_edit.name_modal.add_title": "新增顯示名稱",
"account_edit.name_modal.edit_title": "編輯顯示名稱",
"account_edit.profile_tab.button_label": "自訂",

View File

@@ -23,6 +23,7 @@ import {
createAppSelector,
createDataLoadingThunk,
} from '@/mastodon/store/typed_functions';
import { inputToHashtag } from '@/mastodon/utils/hashtags';
type QueryStatus = 'idle' | 'loading' | 'error';
@@ -82,7 +83,7 @@ const collectionSlice = createSlice({
id: collection?.id ?? null,
name: collection?.name ?? '',
description: collection?.description ?? '',
topic: collection?.tag?.name ?? '',
topic: inputToHashtag(collection?.tag?.name ?? ''),
language: collection?.language ?? '',
discoverable: collection?.discoverable ?? true,
sensitive: collection?.sensitive ?? false,

View File

@@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => {
return `#${words.join('')}${trailingSpace}`;
};
export const hasSpecialCharacters = (input: string) => {
// Regex matches any character NOT a letter/digit, except the #
return /[^a-zA-Z0-9# ]/.test(input);
};

View File

@@ -8478,7 +8478,7 @@ noscript {
gap: 8px;
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
&--desktop {
margin-top: 55px;
@@ -8502,7 +8502,7 @@ noscript {
}
@supports (not (container-type: inline-size)) {
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
@media (min-width: ($button-fallback-breakpoint + 1px)) {
display: none;
}
}

View File

@@ -13,29 +13,29 @@
.logo-container {
margin: 50px auto;
h1 {
display: flex;
justify-content: center;
align-items: center;
.logo {
height: 42px;
margin-inline-end: 10px;
}
a {
display: flex;
justify-content: center;
align-items: center;
width: min-content;
margin: 0 auto;
padding: 12px 16px;
color: var(--color-text-primary);
text-decoration: none;
outline: 0;
padding: 12px 16px;
line-height: 32px;
font-weight: 500;
font-size: 14px;
&:focus-visible {
outline: var(--outline-focus-default);
}
}
.logo {
height: 42px;
margin-inline-end: 10px;
}
}
.compose-standalone {

View File

@@ -77,7 +77,6 @@ code {
.input {
margin-bottom: 16px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
@@ -471,7 +470,8 @@ code {
}
}
.input.radio_buttons .radio label {
.input.radio_buttons .radio {
label {
margin-bottom: 5px;
font-family: inherit;
font-size: 14px;
@@ -480,6 +480,11 @@ code {
width: auto;
}
input[type='radio'] {
accent-color: var(--color-text-brand);
}
}
.check_boxes {
.checkbox {
label {
@@ -503,6 +508,12 @@ code {
}
}
label.checkbox {
input[type='checkbox'] {
accent-color: var(--color-text-brand);
}
}
.input.static .label_input__wrapper {
font-size: 14px;
padding: 10px;
@@ -523,13 +534,20 @@ code {
color: var(--color-text-primary);
display: block;
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
padding: 10px 16px;
outline: var(--outline-focus-default);
outline-offset: -2px;
outline-color: transparent;
transition: outline-color 0.15s ease-out;
&:focus {
outline: var(--outline-focus-default);
}
&:invalid {
box-shadow: none;
@@ -613,6 +631,11 @@ code {
margin-inline-end: 0;
}
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&:active,
&:focus,
&:hover {
@@ -653,6 +676,11 @@ code {
padding-inline-end: 30px;
height: 41px;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
@media screen and (width <= 600px) {
font-size: 16px;
}

View File

@@ -25,7 +25,10 @@ class AccountMigration < ApplicationRecord
before_validation :set_target_account
before_validation :set_followers_count
attribute :current_username, :string
normalizes :acct, with: ->(acct) { acct.strip.delete_prefix('@') }
normalizes :current_username, with: ->(value) { value.strip.delete_prefix('@') }
validates :acct, presence: true, domain: { acct: true }
validate :validate_migration_cooldown
@@ -33,7 +36,7 @@ class AccountMigration < ApplicationRecord
scope :within_cooldown, -> { where(created_at: cooldown_duration_ago..) }
attr_accessor :current_password, :current_username
attr_accessor :current_password
def self.cooldown_duration_ago
Time.current - COOLDOWN_PERIOD

Some files were not shown because too many files have changed in this diff Show More