Skip to content
Merged
23 changes: 23 additions & 0 deletions apps/admin/src/analytics/analytics.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ describe('Analytics overview', () => {
.toContain('Inter Admin 7');
});

it('uses display font features only on Admin 7 headings', async () => {
seedAnalyticsWorld();
seedTopPostsViews();
await renderAdminApp('/analytics', {
labs: { admin7PageChrome: true },
boot: webAnalyticsBootOverrides(),
});

await expect.element(analyticsScreen.membersValue()).toHaveTextContent('175');
const heading = page.getByRole('heading', { name: 'Analytics' });
await expect.element(heading).toBeVisible();
const headingFeatures = getComputedStyle(heading.element()).fontFeatureSettings;
const smallTextFeatures = getComputedStyle(
analyticsScreen.activeVisitors().element(),
).fontFeatureSettings;
expect(headingFeatures).toContain('dlig');
expect(headingFeatures).toContain('cv05');
expect(smallTextFeatures).toContain('zero');
expect(smallTextFeatures).toContain('ss01');
expect(smallTextFeatures).not.toContain('dlig');
expect(smallTextFeatures).not.toContain('cv05');
});

it('re-queries Tinybird when the date range changes', async () => {
const { kpisApi } = seedAnalyticsWorld();
seedTopPostsViews();
Expand Down
21 changes: 20 additions & 1 deletion apps/admin/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,32 @@
'Inter Admin 7', Inter, -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue,
helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif;
--font-family: var(--font-sans);
--font-feature-settings: 'dlig' 1, 'zero' 1, 'ss01' 1, 'cv05' 1;
--font-feature-settings: 'zero' 1, 'ss01' 1;
--heading-font-feature-settings: 'dlig' 1, 'zero' 1, 'ss01' 1, 'cv05' 1;
font-family: var(--font-sans);
font-optical-sizing: none;
font-variation-settings: 'opsz' 14;
font-feature-settings: var(--font-feature-settings);
}

/* Display alternates feel crowded at small sizes, so reserve them for
semantic headings across both Admin screens and Settings overlays. */
:where(
.admin7,
body.react-admin:has(> #root .admin7)
> :is(
.shade.shade-admin,
#ember-basic-dropdown-wormhole,
#ember-modal-wormhole,
#ember-liquid-wormhole,
#ember-alerts-wormhole,
#ember-notifications-wormhole
)
)
:where(h1, h2, h3, h4, h5, h6) {
font-feature-settings: var(--heading-font-feature-settings);
}

/* Browser form-control defaults can reset these inherited font properties.
Keep the rule weaker than any explicit component typography choice. */
:where(
Expand Down
11 changes: 11 additions & 0 deletions apps/admin/src/settings/layout.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('Settings layout', () => {
await renderAdminApp('/settings', { labs: { admin7PageChrome: enabled } });

await expect.element(settingsScreen.search()).toBeVisible();
const heading = page.getByRole('heading', { name: 'General settings', exact: true }).first();
const titleAndDescriptionEdit = settingsScreen
.titleAndDescription()
.getByRole('button', { name: 'Edit' });
Expand All @@ -35,6 +36,16 @@ describe('Settings layout', () => {
.poll(() => getComputedStyle(element).fontFamily.includes('Inter Admin 7'))
.toBe(enabled);
}
if (enabled) {
const headingFeatures = getComputedStyle(heading.element()).fontFeatureSettings;
const searchFeatures = getComputedStyle(
settingsScreen.search().element(),
).fontFeatureSettings;
expect(headingFeatures).toContain('dlig');
expect(headingFeatures).toContain('cv05');
expect(searchFeatures).not.toContain('dlig');
expect(searchFeatures).not.toContain('cv05');
}
expect(document.querySelector('.admin7') !== null).toBe(enabled);
expect(
getComputedStyle(document.querySelector('#root > div')!).getPropertyValue(
Expand Down
10 changes: 8 additions & 2 deletions apps/admin/src/settings/utils/social-urls/linkedin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import type { UsernameRule } from './platform-validator';
// validation info: https://www.linkedin.com/help/linkedin/answer/a542685/manage-your-public-profile-url?lang=en
// Letters and numbers in any language (company/school slugs and vanity URLs
// can contain accented characters — see ONC-1856), hyphens, 3–100 characters.
// Personal /in/ and /pub/ profiles: alphanumeric (any script) and hyphen only.
// Company and school pages also allow underscores (e.g. /company/some_company).
const LINKEDIN_USERNAME_RULE: UsernameRule = {
unicode: true,
extra: '-',
min: 3,
max: 100,
};
const LINKEDIN_COMPANY_USERNAME_RULE: UsernameRule = {
...LINKEDIN_USERNAME_RULE,
extra: '-_',
};

// /in/ profiles are stored as a bare handle; the other path types keep their
// prefix in storage. Regional subdomains (uk.linkedin.com) are valid input and
Expand All @@ -25,8 +31,8 @@ const linkedin = createPlatformValidator({
storagePrefix: 'pub/',
rule: { ...LINKEDIN_USERNAME_RULE, nestedSegments: true },
},
{ urlPrefix: 'company/', storagePrefix: 'company/', rule: LINKEDIN_USERNAME_RULE },
{ urlPrefix: 'school/', storagePrefix: 'school/', rule: LINKEDIN_USERNAME_RULE },
{ urlPrefix: 'company/', storagePrefix: 'company/', rule: LINKEDIN_COMPANY_USERNAME_RULE },
{ urlPrefix: 'school/', storagePrefix: 'school/', rule: LINKEDIN_COMPANY_USERNAME_RULE },
],
errors: {
invalidUrl: 'The URL must be in a format like https://www.linkedin.com/in/yourUsername',
Expand Down
9 changes: 9 additions & 0 deletions apps/admin/src/settings/utils/social-urls/social-urls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ const FIXTURES: PlatformFixture[] = [
['linkedin.com/in/山田太郎', 'https://www.linkedin.com/in/山田太郎'],
// decomposed accents (e + combining acute) normalise to composed form
['linkedin.com/in/josé-garcia', 'https://www.linkedin.com/in/josé-garcia'],
// company/school pages allow underscores; personal /in/ does not
['https://www.linkedin.com/company/eyeshot_2/', 'https://www.linkedin.com/company/eyeshot_2'],
['linkedin.com/company/some_company', 'https://www.linkedin.com/company/some_company'],
['linkedin.com/school/some_school', 'https://www.linkedin.com/school/some_school'],
],
invalid: [
['https://twitter.com/johnsmith', LINKEDIN_URL_ERROR],
Expand All @@ -484,6 +488,7 @@ const FIXTURES: PlatformFixture[] = [
['linkedin.com/in/john%20smith', LINKEDIN_USERNAME_ERROR], // percent-encoded space
['linkedin.com/in/john%3Fsmith', LINKEDIN_USERNAME_ERROR], // percent-encoded ?
['linkedin.com/in/john%2smith', LINKEDIN_USERNAME_ERROR], // malformed percent-encoding
['linkedin.com/in/john_smith', LINKEDIN_USERNAME_ERROR], // underscores not allowed on /in/
// a leftover '@' after the 'company/' prefix mixes two
// incompatible URL conventions and is rejected, not silently stripped
['linkedin.com/company/@acme', LINKEDIN_USERNAME_ERROR],
Expand All @@ -496,6 +501,8 @@ const FIXTURES: PlatformFixture[] = [
['in/johnsmith', 'https://www.linkedin.com/in/johnsmith'],
['company/ghost-foundation', 'https://www.linkedin.com/company/ghost-foundation'],
['company/ghost-foundation/', 'https://www.linkedin.com/company/ghost-foundation'],
['company/eyeshot_2', 'https://www.linkedin.com/company/eyeshot_2'],
['school/some_school', 'https://www.linkedin.com/school/some_school'],
['pub/johnsmith/12/34/567', 'https://www.linkedin.com/pub/johnsmith/12/34/567'],
[
'company/la-revue-européenne-des-médias-et-du-numérique',
Expand All @@ -506,6 +513,7 @@ const FIXTURES: PlatformFixture[] = [
['john@smith', LINKEDIN_USERNAME_ERROR],
['john#smith', LINKEDIN_USERNAME_ERROR],
['john.smith', LINKEDIN_USERNAME_ERROR], // dots are not allowed on linkedin
['john_smith', LINKEDIN_USERNAME_ERROR], // underscores not allowed on personal handles
['jo', LINKEDIN_USERNAME_ERROR], // too short
['a'.repeat(101), LINKEDIN_USERNAME_ERROR], // too long
['company/@acme', LINKEDIN_USERNAME_ERROR],
Expand All @@ -524,6 +532,7 @@ const FIXTURES: PlatformFixture[] = [
['linkedin.com/pub/johnsmith/12/34/567', 'pub/johnsmith/12/34/567'],
['https://www.linkedin.com/company/ghost-foundation', 'company/ghost-foundation'],
['https://www.linkedin.com/company/ghost-foundation/', 'company/ghost-foundation'],
['https://www.linkedin.com/company/eyeshot_2/', 'company/eyeshot_2'],
['linkedin.com/school/mit', 'school/mit'],
[
'https://www.linkedin.com/company/la-revue-europ%C3%A9enne-des-m%C3%A9dias-et-du-num%C3%A9rique/',
Expand Down
40 changes: 40 additions & 0 deletions apps/shade/src/components/patterns/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,11 +524,16 @@ function FilterInput<T = unknown>({
onKeyDown,
onInputChange,
className,
inputRef,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
className?: string;
field?: FilterFieldConfig<T>;
onInputChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
// Taken as a plain prop rather than through forwardRef: this is an internal
// helper, not an exported Shade component, and forwardRef composes badly with
// its generic parameter.
inputRef?: React.Ref<HTMLInputElement>;
}) {
const context = useFilterContext();
const [isValid, setIsValid] = useState(true);
Expand Down Expand Up @@ -649,6 +654,7 @@ function FilterInput<T = unknown>({

<div className="flex w-full items-stretch">
<input
ref={inputRef}
aria-describedby={
!isValid && validationMessage ? `${field?.key || 'input'}-error` : undefined
}
Expand Down Expand Up @@ -1471,13 +1477,22 @@ export function FilterSegmentInput({
);
}

// Types whose filter is created empty and answered by typing, so focus belongs
// in the input as soon as it appears. Deliberately excludes the date-like types
// and `numberrange`, which are created already carrying a usable value and so
// aren't waiting on input; and the pickers, which open a menu that takes focus
// on its own.
const TYPED_VALUE_FIELD_TYPES = ['text', 'number', 'email', 'url', 'tel'];

interface FilterValueSelectorProps<T = unknown> {
field: FilterFieldConfig<T>;
values: T[];
onChange: (values: T[]) => void;
operator: string;
onOperatorChange?: (operator: string) => void;
readOnly?: boolean;
/** Focus the value input on mount — set for a filter the user just added. */
autoFocus?: boolean;
}

interface SelectOptionsPopoverProps<T = unknown> {
Expand Down Expand Up @@ -2053,10 +2068,12 @@ function FilterValueSelector<T = unknown>({
operator,
onOperatorChange,
readOnly,
autoFocus,
}: FilterValueSelectorProps<T>) {
const [open, setOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const context = useFilterContext();
const valueInputRef = useRef<HTMLInputElement>(null);

// Focus the search input when the popover opens
useEffect(() => {
Expand All @@ -2071,6 +2088,14 @@ function FilterValueSelector<T = unknown>({
}
}, [open, field.searchable]);

// A filter the user just added lands with an empty value, so put the caret
// where the answer goes instead of leaving them to click into it.
useEffect(() => {
if (autoFocus) {
valueInputRef.current?.focus();
}
}, [autoFocus]);

// Hide value input for empty/not empty operators
if (operator === 'empty' || operator === 'not_empty') {
return null;
Expand Down Expand Up @@ -2279,6 +2304,7 @@ function FilterValueSelector<T = unknown>({
<FilterInput
className={field.className}
field={field}
inputRef={valueInputRef}
pattern={field.pattern || getPattern()}
placeholder={
field.placeholder || context.i18n.placeholders.enterField(field.type || 'text')
Expand Down Expand Up @@ -2336,6 +2362,7 @@ function FilterValueSelector<T = unknown>({
<FilterInput
className={cn('w-16 max-w-full', field.className)}
field={field}
inputRef={valueInputRef}
max={field.max}
min={field.min}
pattern={field.pattern}
Expand Down Expand Up @@ -2374,6 +2401,7 @@ function FilterValueSelector<T = unknown>({
<FilterInput
className={cn('w-36', field.className)}
field={field}
inputRef={valueInputRef}
max={field.type === 'number' ? field.max : undefined}
min={field.type === 'number' ? field.min : undefined}
pattern={field.pattern}
Expand Down Expand Up @@ -2717,6 +2745,10 @@ export function Filters<T = unknown>({
const [addFilterOpen, setAddFilterOpen] = useState(false);
const [selectedFieldKeyForOptions, setSelectedFieldKeyForOptions] = useState<string | null>(null);
const [tempSelectedValues, setTempSelectedValues] = useState<unknown[]>([]);
// The filter added most recently, so its input can take focus once it renders.
// Holding the id rather than a boolean keeps it pinned to that one row, which
// a positional guess would lose as soon as filters are added or removed.
const [autoFocusFilterId, setAutoFocusFilterId] = useState<string | null>(null);
// The field-picker search, controlled so a `previewLimit` group can uncap while
// the user is searching. `expandedGroups` holds the groups whose "Show more" was
// clicked. Both reset when the picker closes.
Expand Down Expand Up @@ -2874,6 +2906,13 @@ export function Filters<T = unknown>({

const newFilter = createFilter<T>(fieldKey, defaultOperator, defaultValues as T[]);
onChange([...filters, newFilter]);

// Picker types are excluded here because adding one opens its options
// popover, which already takes focus.
if (TYPED_VALUE_FIELD_TYPES.includes(field.type || '')) {
setAutoFocusFilterId(newFilter.id);
}

closeFilterPopover();
},
[allowMultiple, closeFilterPopover, fieldsMap, filters, onChange],
Expand Down Expand Up @@ -3052,6 +3091,7 @@ export function Filters<T = unknown>({
static segments, so the filter stays legible while only
the remove control acts. */}
<FilterValueSelector<T>
autoFocus={filter.id === autoFocusFilterId}
field={field}
operator={filter.operator}
readOnly={field.readOnly}
Expand Down
Loading
Loading