mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(elements): add logto-username element (#6724)
This commit is contained in:
parent
2d3295ea28
commit
6ef19b3e73
11 changed files with 345 additions and 4 deletions
|
@ -5,13 +5,25 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Logto elements dev page</title>
|
||||
<script type="module" src="./src/account/index.ts"></script>
|
||||
<link rel="stylesheet" href="src/account/style.css">
|
||||
<script type="module" src="src/account/index.ts"></script>
|
||||
</head>
|
||||
|
||||
<body style="background: #ecebf5;">
|
||||
<body style="background-color: #ecebf5;">
|
||||
<logto-account-provider>
|
||||
Logto Account Provider
|
||||
<logto-username></logto-username>
|
||||
</logto-account-provider>
|
||||
</body>
|
||||
|
||||
<script type="module">
|
||||
import { LogtoAccountApi } from './src/account/api/index.ts';
|
||||
|
||||
// Replace with your logto endpoint when developing locally
|
||||
const LOGTO_ENDPOINT = 'TEST_LOGTO_ENDPOINT';
|
||||
const ACCESS_TOKEN = 'TEST_ACCESS_TOKEN';
|
||||
|
||||
const provider = document.querySelector('logto-account-provider');
|
||||
const api = new LogtoAccountApi(LOGTO_ENDPOINT, () => ACCESS_TOKEN);
|
||||
provider.accountApi = api;
|
||||
</script>
|
||||
|
||||
</html>
|
||||
|
|
34
packages/elements/src/account/components/logto-icon.ts
Normal file
34
packages/elements/src/account/components/logto-icon.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { css, html, LitElement } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
const tagName = 'logto-icon';
|
||||
|
||||
/**
|
||||
* LogtoIcon: A custom element for consistent icon rendering
|
||||
*
|
||||
* It allows configuring the icon size within the shadow DOM using the `--logto-icon-size` CSS variable,
|
||||
* which is necessary because external CSS cannot affect styles inside the shadow DOM
|
||||
*/
|
||||
@customElement(tagName)
|
||||
export class LogtoIcon extends LitElement {
|
||||
static tagName = tagName;
|
||||
|
||||
static styles = css`
|
||||
::slotted(svg) {
|
||||
display: block;
|
||||
width: var(--logto-icon-size, 24px);
|
||||
height: var(--logto-icon-size, 24px);
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface HTMLElementTagNameMap {
|
||||
[tagName]: LogtoIcon;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import { css, html, LitElement } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
const tagName = 'logto-profile-item';
|
||||
|
||||
/**
|
||||
* LogtoProfileItem: A custom element for displaying profile information
|
||||
*
|
||||
* It provides a consistent layout and styling for profile-related items
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* <logto-profile-item>
|
||||
* <logto-icon slot="label-icon">...</logto-icon>
|
||||
* <div slot="label-text">Label</div>
|
||||
* <div slot="content">Content</div>
|
||||
* </logto-profile-item>
|
||||
*/
|
||||
@customElement(tagName)
|
||||
export class LogtoProfileItem extends LitElement {
|
||||
static tagName = tagName;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--logto-profile-item-container-color, var(--logto-color-background));
|
||||
border-radius: var(--logto-profile-item-container-shape, var(--logto-shape-corner-lg));
|
||||
padding-inline-start: var(
|
||||
--logto-profile-item-container-leading-space,
|
||||
var(--logto-spacing-xl)
|
||||
);
|
||||
padding-inline-end: var(
|
||||
--logto-profile-item-container-trailing-space,
|
||||
var(--logto-spacing-xl)
|
||||
);
|
||||
height: var(--logto-profile-item-height, 64px);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--logto-profile-item-label-gap, var(--logto-spacing-sm));
|
||||
}
|
||||
|
||||
::slotted([slot='label-icon']) {
|
||||
color: var(--logto-profile-item-label-icon-color, var(--logto-color-typeface-secondary));
|
||||
|
||||
--logto-icon-size: var(--logto-profile-item-label-icon-size, 24px);
|
||||
}
|
||||
|
||||
::slotted([slot='label-text']) {
|
||||
font: var(--logto-profile-item-label-font, var(--logto-font-label-md));
|
||||
color: var(--logto-profile-item-label-color, var(--logto-color-typeface-primary));
|
||||
}
|
||||
|
||||
::slotted([slot='content']),
|
||||
slot[name='content'] {
|
||||
display: flex;
|
||||
flex: 2;
|
||||
font: var(--logto-profile-item-value-font, var(--logto-font-body-md));
|
||||
color: var(--logto-profile-item--color, var(--logto-color-typeface-primary));
|
||||
}
|
||||
|
||||
.no-value {
|
||||
font: var(--logto-profile-item-no-value-font, var(--logto-font-body-md));
|
||||
color: var(--logto-profile-item-no-value-color, var(--logto-color-typeface-secondary));
|
||||
}
|
||||
|
||||
::slotted([slot='actions']) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="label">
|
||||
<slot name="label-icon"></slot>
|
||||
<slot name="label-text"></slot>
|
||||
</div>
|
||||
<slot name="content"><span class="no-value">Not set</span></slot>
|
||||
<slot name="actions"></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface HTMLElementTagNameMap {
|
||||
[tagName]: LogtoProfileItem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { consume } from '@lit/context';
|
||||
import { html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||
|
||||
import {
|
||||
logtoAccountContext,
|
||||
type LogtoAccountContextType,
|
||||
} from '../providers/logto-account-provider.js';
|
||||
|
||||
export abstract class LogtoProfileItemElement extends LitElement {
|
||||
@consume({ context: logtoAccountContext, subscribe: true })
|
||||
protected readonly accountContext?: LogtoAccountContextType;
|
||||
|
||||
render() {
|
||||
if (!this.accountContext) {
|
||||
return html`<span>Unable to retrieve account context.</span>`;
|
||||
}
|
||||
|
||||
if (!this.isAccessible()) {
|
||||
// Render nothing if the user lacks permission to view phone number information
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { icon, label } = this.getItemLabelInfo();
|
||||
|
||||
return html`
|
||||
<logto-profile-item>
|
||||
<logto-icon slot="label-icon">${icon}</logto-icon>
|
||||
<div slot="label-text">${label}</div>
|
||||
${this.renderContent()}
|
||||
</logto-profile-item>
|
||||
`;
|
||||
}
|
||||
/**
|
||||
* Determines whether the user has permission to view this profile item.
|
||||
*
|
||||
* When the user lacks permission, the corresponding field in the userProfile data will be `undefined`.
|
||||
*/
|
||||
protected abstract isAccessible(): boolean;
|
||||
|
||||
/**
|
||||
* The icon and label of the profile item.
|
||||
*/
|
||||
protected abstract getItemLabelInfo(): {
|
||||
icon: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The content of the profile item.
|
||||
*
|
||||
* This is the content that should be placed in the slot="content" position.
|
||||
*/
|
||||
protected abstract renderContent(): TemplateResult;
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import { assert, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
|
||||
import { createMockAccountApi } from '../__mocks__/account-api.js';
|
||||
import { type LogtoAccountProvider } from '../providers/logto-account-provider.js';
|
||||
|
||||
import { LogtoUsername } from './logto-username.js';
|
||||
|
||||
const fakeLogtoEndpoint = 'https://logto.dev';
|
||||
|
||||
suite('logto-username', () => {
|
||||
test('is defined', () => {
|
||||
const element = document.createElement(LogtoUsername.tagName);
|
||||
assert.instanceOf(element, LogtoUsername);
|
||||
});
|
||||
|
||||
test('should render error message when account context is not available', async () => {
|
||||
const element = await fixture<LogtoUsername>(html`<logto-username></logto-username>`);
|
||||
await element.updateComplete;
|
||||
|
||||
assert.equal(element.shadowRoot?.textContent, 'Unable to retrieve account context.');
|
||||
});
|
||||
|
||||
test('should render username if the user has permission to view username information', async () => {
|
||||
const mockAccountApi = createMockAccountApi({
|
||||
fetchUserProfile: async () => ({
|
||||
username: 'test_username',
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = await fixture<LogtoAccountProvider>(
|
||||
html`<logto-account-provider .accountApi=${mockAccountApi}>
|
||||
<logto-username></logto-username>
|
||||
</logto-account-provider>`
|
||||
);
|
||||
|
||||
await provider.updateComplete;
|
||||
|
||||
const logtoUsername = provider.querySelector<LogtoUsername>(LogtoUsername.tagName);
|
||||
|
||||
await waitUntil(
|
||||
() =>
|
||||
logtoUsername?.shadowRoot?.querySelector('div[slot="content"]')?.textContent ===
|
||||
'test_username',
|
||||
'Unable to get username from account context'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render nothing if the user lacks permission to view username information', async () => {
|
||||
const mockAccountApi = createMockAccountApi({
|
||||
fetchUserProfile: async () => ({
|
||||
username: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = await fixture<LogtoAccountProvider>(
|
||||
html`<logto-account-provider .accountApi=${mockAccountApi}>
|
||||
<logto-username></logto-username>
|
||||
</logto-account-provider>`
|
||||
);
|
||||
|
||||
await provider.updateComplete;
|
||||
const logtoUsername = provider.querySelector<LogtoUsername>(LogtoUsername.tagName);
|
||||
|
||||
await logtoUsername?.updateComplete;
|
||||
assert.equal(logtoUsername?.shadowRoot?.children.length, 0);
|
||||
});
|
||||
});
|
35
packages/elements/src/account/elements/logto-username.ts
Normal file
35
packages/elements/src/account/elements/logto-username.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { html, type TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import usernameIcon from '../icons/username.svg';
|
||||
|
||||
import { LogtoProfileItemElement } from './LogtoProfileItemElement.js';
|
||||
|
||||
const tagName = 'logto-username';
|
||||
|
||||
@customElement(tagName)
|
||||
export class LogtoUsername extends LogtoProfileItemElement {
|
||||
static tagName = tagName;
|
||||
|
||||
protected isAccessible() {
|
||||
return this.accountContext?.userProfile.username !== undefined;
|
||||
}
|
||||
|
||||
protected getItemLabelInfo() {
|
||||
return { icon: usernameIcon, label: 'Username' };
|
||||
}
|
||||
|
||||
protected renderContent(): TemplateResult {
|
||||
const { username } = this.accountContext?.userProfile ?? {};
|
||||
|
||||
return when(username, (_username) => html`<div slot="content">${_username}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface HTMLElementTagNameMap {
|
||||
[tagName]: LogtoUsername;
|
||||
}
|
||||
}
|
4
packages/elements/src/account/icons/index.d.ts
vendored
Normal file
4
packages/elements/src/account/icons/index.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
declare module '*.svg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
3
packages/elements/src/account/icons/username.svg
Normal file
3
packages/elements/src/account/icons/username.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.0001 1.66669C8.38385 1.66976 6.80336 2.14278 5.45115 3.02814C4.09893 3.91349 3.03337 5.17297 2.38424 6.65316C1.73511 8.13335 1.53044 9.77037 1.79514 11.3648C2.05985 12.9593 2.78252 14.4423 3.87512 15.6334C4.65547 16.4792 5.60257 17.1543 6.65674 17.616C7.7109 18.0778 8.84927 18.3161 10.0001 18.3161C11.151 18.3161 12.2893 18.0778 13.3435 17.616C14.3977 17.1543 15.3448 16.4792 16.1251 15.6334C17.2177 14.4423 17.9404 12.9593 18.2051 11.3648C18.4698 9.77037 18.2651 8.13335 17.616 6.65316C16.9669 5.17297 15.9013 3.91349 14.5491 3.02814C13.1969 2.14278 11.6164 1.66976 10.0001 1.66669ZM10.0001 16.6667C8.27384 16.6641 6.61588 15.9919 5.37512 14.7917C5.75183 13.8746 6.39265 13.0903 7.21617 12.5383C8.03968 11.9863 9.00871 11.6915 10.0001 11.6915C10.9915 11.6915 11.9606 11.9863 12.7841 12.5383C13.6076 13.0903 14.2484 13.8746 14.6251 14.7917C13.3844 15.9919 11.7264 16.6641 10.0001 16.6667ZM8.33346 8.33335C8.33346 8.00372 8.4312 7.68149 8.61434 7.4074C8.79748 7.13332 9.05777 6.9197 9.36232 6.79355C9.66686 6.66741 10.002 6.6344 10.3253 6.69871C10.6486 6.76302 10.9455 6.92176 11.1786 7.15484C11.4117 7.38793 11.5705 7.6849 11.6348 8.0082C11.6991 8.33151 11.6661 8.66662 11.5399 8.97116C11.4138 9.2757 11.2002 9.536 10.9261 9.71914C10.652 9.90227 10.3298 10 10.0001 10C9.55809 10 9.13417 9.82443 8.82161 9.51187C8.50905 9.1993 8.33346 8.77538 8.33346 8.33335ZM15.7585 13.3334C15.0139 12.0598 13.868 11.0692 12.5001 10.5167C12.9244 10.0356 13.2009 9.44224 13.2963 8.80788C13.3918 8.17353 13.3022 7.52511 13.0382 6.94043C12.7743 6.35575 12.3473 5.85965 11.8084 5.51166C11.2695 5.16366 10.6416 4.97856 10.0001 4.97856C9.35863 4.97856 8.73076 5.16366 8.19187 5.51166C7.65297 5.85965 7.22593 6.35575 6.962 6.94043C6.69806 7.52511 6.60844 8.17353 6.7039 8.80788C6.79935 9.44224 7.07582 10.0356 7.50012 10.5167C6.13229 11.0692 4.98635 12.0598 4.24179 13.3334C3.64841 12.3226 3.33489 11.1721 3.33346 10C3.33346 8.23191 4.03583 6.53622 5.28608 5.28598C6.53632 4.03573 8.23201 3.33335 10.0001 3.33335C11.7682 3.33335 13.4639 4.03573 14.7142 5.28598C15.9644 6.53622 16.6668 8.23191 16.6668 10C16.6654 11.1721 16.3518 12.3226 15.7585 13.3334Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
|
@ -1,3 +1,8 @@
|
|||
export * from './api/index.js';
|
||||
|
||||
export * from './components/logto-icon.js';
|
||||
export * from './components/logto-profile-item.js';
|
||||
|
||||
export * from './providers/logto-account-provider.js';
|
||||
|
||||
export * from './elements/logto-username.js';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createComponent } from '@lit/react';
|
||||
|
||||
import { LogtoUsername } from './elements/logto-username.js';
|
||||
import { LogtoAccountProvider } from './index.js';
|
||||
|
||||
export * from './api/index.js';
|
||||
|
@ -11,5 +12,10 @@ export const createReactComponents = (react: Parameters<typeof createComponent>[
|
|||
elementClass: LogtoAccountProvider,
|
||||
react,
|
||||
}),
|
||||
LogtoUsername: createComponent({
|
||||
tagName: LogtoUsername.tagName,
|
||||
elementClass: LogtoUsername,
|
||||
react,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
27
packages/elements/src/account/style.css
Normal file
27
packages/elements/src/account/style.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
:root {
|
||||
/* Colors */
|
||||
--logto-color-background: #ffffff;
|
||||
--logto-color-typeface-primary: #191C1D;
|
||||
--logto-color-typeface-secondary: #747778;
|
||||
--logto-color-container-on-success: #68BE6C;
|
||||
|
||||
/* Shapes */
|
||||
--logto-shape-corner-lg: 12px;
|
||||
--logto-shape-corner-md: 8px;
|
||||
|
||||
/* Spacing */
|
||||
--logto-spacing-xl: 20px;
|
||||
--logto-spacing-lg: 16px;
|
||||
--logto-spacing-md: 12px;
|
||||
--logto-spacing-sm: 8px;
|
||||
--logto-spacing-xs: 4px;
|
||||
|
||||
/* Fonts */
|
||||
--logto-font-label-lg: 500 16px/24px 'SF Pro Text', sans-serif;
|
||||
--logto-font-label-md: 500 14px/20px 'SF Pro Text', sans-serif;
|
||||
--logto-font-label-sm: 500 12px/16px 'SF Pro Text', sans-serif;
|
||||
|
||||
--logto-font-body-lg: 400 16px/24px 'SF Pro Text', sans-serif;
|
||||
--logto-font-body-md: 400 14px/20px 'SF Pro Text', sans-serif;
|
||||
--logto-font-body-sm: 400 12px/16px 'SF Pro Text', sans-serif;
|
||||
}
|
Loading…
Reference in a new issue