Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Users can now connect their accounts to one or more Socialite providers. ([#19202](https://github.com/craftcms/cms/pull/19202))
- The login page now lists Socialite providers. ([#19202](https://github.com/craftcms/cms/pull/19202))
- Fixed a bug where some control panel resources and pages weren’t loading properly. ([#19214](https://github.com/craftcms/cms/issues/19214))

## 6.0.0-alpha.10 - 2026-07-03
Expand Down
4 changes: 3 additions & 1 deletion docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,14 @@ Each provider supports the following keys:
- `clientSecret` optional for named drivers when already defined in Laravel's `services` config. Required for provider classes.
- `name` optional. Human-friendly provider name.
- `label` optional. Button label. Defaults to `Sign in with {name}`.
- `icon` optional. Control panel brand icon name for the sign-in providers screen.
- `scopes` optional. Array of scopes passed to Socialite.
- `with` optional. Array of extra request parameters passed to the provider.
- `stateless` optional. Set to `true` to bypass Socialite state validation.
- `groups` optional. Array of user group IDs, UIDs, or handles to assign to newly-created users.
- `createsUsers` optional. Defaults to `null`, which inherits Craft's public registration setting.
- `activatesUsers` optional. Defaults to `false`.
- `trustsEmail` optional. Defaults to `true` for known trusted providers (`google`, `github`, `apple`, `bitbucket`, `slack`, `slack-openid`, and `twitter-oauth-2`), and `false` otherwise. Set to `true` only when the provider is trusted to verify ownership of the returned email address; this allows first-time matching to existing Craft users by email.
- `trustsEmail` optional. Defaults to `false`. Set to `true` only when the provider is trusted to verify ownership of the returned email address; this allows first-time matching to existing Craft users by email.
- `identityResolver` optional. Class implementing `\CraftCms\Cms\Auth\OAuth\Contracts\ResolvesOAuthIdentity`.
- `userResolver` optional. Class implementing `\CraftCms\Cms\Auth\OAuth\Contracts\ResolvesOAuthUser`.
- `userPopulator` optional. Class implementing `\CraftCms\Cms\Auth\OAuth\Contracts\PopulatesOAuthUser`.
Expand All @@ -83,6 +84,7 @@ return GeneralConfig::create()
'clientId' => env('GITHUB_CLIENT_ID'),
'clientSecret' => env('GITHUB_CLIENT_SECRET'),
'label' => 'Continue with GitHub',
'icon' => 'github',
'scopes' => ['read:user', 'user:email'],
'groups' => ['members', 'editors'],
'createsUsers' => true,
Expand Down
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions packages/craftcms-cp/src/components/button/button.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,35 @@ export const Loading: Story = {
<craft-button ?loading="${args.loading}"> Submit </craft-button>
`,
};

export const Links: Story = {
args: {},
render: () => html`
<div class="grid gap-4">
<div class="flex gap-2 items-center">
${appearance.map(
(a) => html`
<craft-button appearance="${a}" href="#" variant="accent"
>${a} link</craft-button
>
`
)}
</div>
<div class="flex gap-2 items-center">
${['zero', 'small', 'medium', 'large'].map(
(size) =>
html`<craft-button href="#" size="${size}">${size}</craft-button>`
)}
</div>
<div class="flex gap-2 items-center">
<craft-button href="https://craftcms.com" target="_blank"
>New tab</craft-button
>
<craft-button href="/file.zip" download="file.zip"
>Download</craft-button
>
<craft-button href="#" disabled>Disabled link</craft-button>
</div>
</div>
`,
};
63 changes: 63 additions & 0 deletions packages/craftcms-cp/src/components/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,67 @@ export default css`
transform: translateX(-100%);
}
}

/*
Link mode: the inner <a> is the full interactive surface.
Move inline padding from :host to the anchor so the whole button is clickable.
*/
:host([href]:not([disabled])) {
padding-inline: 0;

/* Lion's minimum-click-target overlay is positioned, so it paints above
the (non-positioned) anchor and swallows every pointer click before it
can activate the link. Recreate the overlay on the anchor instead, so
the full target navigates. */
&::before {
display: none;
}
}

.link {
position: relative;
display: flex;
align-items: center;
justify-content: inherit;
gap: inherit;
inline-size: 100%;
/* Stretch to the host's full cross size so the whole button (including
block padding) is the clickable link. The host's height is indefinite
(min-height), so a percentage min-block-size would not resolve. */
align-self: stretch;
color: inherit;
font: inherit;
text-decoration: none;
padding-inline: var(
--c-button-spacing-inline,
var(--c-form-control-spacing-inline)
);

/* Same minimum click area as Lion's :host::before (WCAG 2.5.5), but as
part of the anchor so clicks on it follow the link. */
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-height: 44px;
min-width: 44px;
width: 100%;
height: 100%;
}
}

:host([href][size~='small']:not([disabled])) .link {
padding-inline: var(--c-spacing-sm);
}

:host([href][size~='large']:not([disabled])) .link {
padding-inline: var(--c-spacing-lg);
}

:host([href][size~='zero']:not([disabled])) .link,
:host([href][icon]:not([disabled])) .link {
padding-inline: 0;
}
`;
160 changes: 160 additions & 0 deletions packages/craftcms-cp/src/components/button/button.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {beforeEach, describe, expect, it} from 'vitest';
import type CraftButton from './button.js';
import './button.js';

async function createButton(
attrs: Record<string, string> = {},
text = 'Label'
): Promise<CraftButton> {
const element = document.createElement('craft-button') as CraftButton;
for (const [name, value] of Object.entries(attrs)) {
element.setAttribute(name, value);
}
element.textContent = text;
document.body.append(element);
await element.updateComplete;
return element;
}

function anchor(element: CraftButton): HTMLAnchorElement | null {
return element.shadowRoot?.querySelector('a.link') ?? null;
}

beforeEach(() => {
document.body.innerHTML = '';
});

describe('craft-button link mode', () => {
it('renders no anchor when href is absent', async () => {
const element = await createButton();
expect(anchor(element)).toBeNull();
expect(element.shadowRoot?.querySelector('.button-content')).not.toBeNull();
});

it('renders a real anchor wrapping the content when href is set', async () => {
const element = await createButton({href: '/settings'});
const a = anchor(element);
expect(a).not.toBeNull();
expect(a!.getAttribute('href')).toBe('/settings');
expect(a!.querySelector('.button-content')).not.toBeNull();
});

it('forwards target and download to the anchor', async () => {
const element = await createButton({
href: '/file.zip',
target: '_self',
download: 'file.zip',
});
const a = anchor(element)!;
expect(a.getAttribute('target')).toBe('_self');
expect(a.getAttribute('download')).toBe('file.zip');
});

it('adds noopener to rel when target is _blank', async () => {
const element = await createButton({href: '/x', target: '_blank'});
expect(anchor(element)!.getAttribute('rel')).toContain('noopener');
});

it('preserves an explicit rel and still adds noopener for _blank', async () => {
const element = await createButton({
href: '/x',
target: '_blank',
rel: 'nofollow',
});
const rel = anchor(element)!.getAttribute('rel')!;
expect(rel).toContain('nofollow');
expect(rel).toContain('noopener');
});

it('does not set rel when target is not _blank and no rel given', async () => {
const element = await createButton({href: '/x'});
expect(anchor(element)!.hasAttribute('rel')).toBe(false);
});

it('does not flag an accessible-name error for a labeled link', async () => {
const element = await createButton({href: '/x'}, 'Settings');
// Wait for firstUpdated's async accessible-name computation.
await element.updateComplete;
expect(element.accessibleName).toBe('Settings');
});
});

describe('craft-button link semantics', () => {
it('is a presentation host and not a tab stop in link mode', async () => {
const element = await createButton({href: '/x'});
expect(element.getAttribute('role')).toBe('presentation');
expect(element.tabIndex).toBe(-1);
expect(element.type).toBe('button');
});

it('keeps Lion button semantics when there is no href', async () => {
const element = await createButton();
expect(element.getAttribute('role')).toBe('button');
expect(element.tabIndex).toBe(0);
expect(element.type).toBe('submit');
});

it('treats disabled+href as an inert button, not a link', async () => {
const element = await createButton({href: '/x', disabled: ''});
expect(anchor(element)).toBeNull();
expect(element.getAttribute('aria-disabled')).toBe('true');
expect(element.getAttribute('role')).toBe('button');
});

it('does not submit a form when a link-mode button is clicked', async () => {
const form = document.createElement('form');
const element = document.createElement('craft-button') as CraftButton;
element.setAttribute('href', '/x');
element.textContent = 'Go';
form.append(element);
document.body.append(form);
await element.updateComplete;

let submitted = false;
form.addEventListener('submit', (e) => {
submitted = true;
e.preventDefault();
});
element.click();

expect(submitted).toBe(false);
});

it('re-syncs host state when href is added after connect', async () => {
const element = await createButton();
expect(element.getAttribute('role')).toBe('button');

element.href = '/later';
await element.updateComplete;

expect(element.getAttribute('role')).toBe('presentation');
expect(element.tabIndex).toBe(-1);
});

it('leaves a disabled non-link button non-focusable (tabIndex -1)', async () => {
const element = await createButton({disabled: ''});
expect(element.tabIndex).toBe(-1);
});

it('keeps disabled+href non-focusable (tabIndex -1)', async () => {
const element = await createButton({href: '/x', disabled: ''});
expect(element.tabIndex).toBe(-1);
expect(anchor(element)).toBeNull();
});

it('does not override an explicit type on a plain button', async () => {
const element = await createButton({type: 'button'});
expect(element.type).toBe('button');
});

it('restores button semantics when href is removed at runtime', async () => {
const element = await createButton({href: '/x'});
expect(element.getAttribute('role')).toBe('presentation');

element.href = null;
await element.updateComplete;

expect(element.getAttribute('role')).toBe('button');
expect(element.tabIndex).toBe(0);
});
});
Loading