From c63cd89c96ecd1cb003e7d178f50c4c7f792ea37 Mon Sep 17 00:00:00 2001 From: mantou132 <709922234@qq.com> Date: Fri, 19 Jul 2024 00:48:00 +0800 Subject: [PATCH] [gem] Light dom use `@scope` --- packages/duoyun-ui/src/elements/form.ts | 67 ++++++----- packages/duoyun-ui/src/elements/input.ts | 41 +++---- packages/duoyun-ui/src/elements/paragraph.ts | 28 +++-- packages/duoyun-ui/src/elements/space.ts | 8 +- packages/duoyun-ui/src/lib/styles.ts | 14 ++- packages/duoyun-ui/src/lib/theme.ts | 4 +- packages/duoyun-ui/src/patterns/console.ts | 102 +++++++--------- packages/duoyun-ui/src/patterns/footer.ts | 61 +++++----- packages/duoyun-ui/src/patterns/nav.ts | 64 +++++----- packages/gem-book/src/plugins/api.ts | 14 +-- packages/gem-devtools/src/theme.ts | 6 +- packages/gem-examples/src/scope/index.ts | 19 +-- packages/gem-examples/src/styled/index.ts | 25 ++-- packages/gem-examples/src/theme/index.ts | 48 +++++--- packages/gem/src/helper/theme.ts | 83 ++++++------- packages/gem/src/lib/element.ts | 2 +- packages/gem/src/lib/utils.ts | 112 +++++++++++------- .../gem/src/test/gem-element/advance.test.ts | 13 +- packages/gem/src/test/utils.test.ts | 33 +++--- 19 files changed, 389 insertions(+), 355 deletions(-) diff --git a/packages/duoyun-ui/src/elements/form.ts b/packages/duoyun-ui/src/elements/form.ts index d49fcc17..a4cd3886 100644 --- a/packages/duoyun-ui/src/elements/form.ts +++ b/packages/duoyun-ui/src/elements/form.ts @@ -38,40 +38,45 @@ import './date-picker'; import './date-range-picker'; const formStyle = createCSSSheet(css` - :where(dy-form:not([hidden])) { + :where(:scope:not([inline], [hidden])) { display: block; } - dy-form:not([inline]) dy-form-item { - margin-block-end: 1.8em; - } - :where(dy-form[inline]:not([hidden])) { + :where(:scope[inline]:not([hidden])) { display: flex; - flex-wrap: wrap; - gap: 1em; } - dy-form[inline] dy-form-item { - position: relative; - gap: 0.5em; - align-items: center; - flex-direction: row; - flex-grow: 0; - margin-block-end: 0; - } - dy-form[inline] dy-form-item::part(label) { - margin-block-end: 0; - } - dy-form[inline] dy-form-item::part(input) { - width: 15em; - } - dy-form[inline] dy-form-item::part(input), - dy-form[inline] dy-form-item::part(add) { - margin-block-start: 0; + :scope:not([inline]) { + dy-form-item { + margin-block-end: 1.8em; + } } - dy-form[inline] dy-form-item::part(tip) { - position: absolute; - width: 100%; - top: 100%; - left: 0; + :scope[inline] { + flex-wrap: wrap; + gap: 1em; + + dy-form-item { + position: relative; + gap: 0.5em; + align-items: center; + flex-direction: row; + flex-grow: 0; + margin-block-end: 0; + } + dy-form-item::part(label) { + margin-block-end: 0; + } + dy-form-item::part(input) { + width: 15em; + } + dy-form-item::part(input), + dy-form-item::part(add) { + margin-block-start: 0; + } + dy-form-item::part(tip) { + position: absolute; + width: 100%; + top: 100%; + left: 0; + } } dy-form-item:state(invalid) * { border-color: ${theme.negativeColor}; @@ -81,7 +86,7 @@ const formStyle = createCSSSheet(css` display: flex; gap: 1em; } - dy-form:not([inline]) dy-form-item-inline-group > dy-form-item { + :scope:not([inline]) dy-form-item-inline-group > dy-form-item { width: 0; flex-grow: 1; } @@ -382,6 +387,7 @@ export class DuoyunFormItemElement extends GemElement { ? html` evt.target.change(undefined)} ?disabled=${this.disabled} @@ -396,6 +402,7 @@ export class DuoyunFormItemElement extends GemElement { ? html` evt.target.change(undefined)} ?disabled=${this.disabled} diff --git a/packages/duoyun-ui/src/elements/input.ts b/packages/duoyun-ui/src/elements/input.ts index 07b79415..2e6c19c8 100644 --- a/packages/duoyun-ui/src/elements/input.ts +++ b/packages/duoyun-ui/src/elements/input.ts @@ -412,27 +412,28 @@ export class DuoyunInputElement extends GemElement { } const inputGroupStyle = createCSSSheet(css` - dy-input-group { + :scope { display: flex; - } - dy-input-group :where(dy-input, dy-select):where(:focus, :focus-within, :hover, :state(active)) { - position: relative; - z-index: 1; - } - dy-input-group > * { - flex-grow: 10; - width: 0; - } - dy-input-group > :not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - dy-input-group > :nth-child(n + 2), - /* dy-input-group > dy-tooltip > dy-button */ - dy-input-group > dy-tooltip:last-child > * { - margin-inline-start: -1px; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + + :where(dy-input, dy-select):where(:focus, :focus-within, :hover, :state(active)) { + position: relative; + z-index: 1; + } + > * { + flex-grow: 10; + width: 0; + } + > :not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + > :nth-child(n + 2), + /* > dy-tooltip > dy-button */ + > dy-tooltip:last-child > * { + margin-inline-start: -1px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } } `); diff --git a/packages/duoyun-ui/src/elements/paragraph.ts b/packages/duoyun-ui/src/elements/paragraph.ts index cd846f7e..8bbb18c9 100644 --- a/packages/duoyun-ui/src/elements/paragraph.ts +++ b/packages/duoyun-ui/src/elements/paragraph.ts @@ -1,26 +1,30 @@ -import { adoptedStyle, aria, customElement, slot } from '@mantou/gem/lib/decorators'; +import { adoptedStyle, aria, customElement } from '@mantou/gem/lib/decorators'; import { GemElement } from '@mantou/gem/lib/element'; import { createCSSSheet, css } from '@mantou/gem/lib/utils'; import { theme } from '../lib/theme'; const style = createCSSSheet(css` - :where(dy-paragraph:not([hidden])) { + :where(:scope:not([hidden])) { display: block; margin-block-end: 0.75em; line-height: 1.5; - } - :where(dy-paragraph):where(:lang(zh), :lang(ja), :lang(kr)) { - line-height: 1.7; - } - :where(dy-paragraph) { - :where(gem-link, dy-link):where(:not([hidden])) { - display: inline-block; + + &:where(:lang(zh), :lang(ja), :lang(kr)) { + line-height: 1.7; + + :where(gem-link, dy-link, a[href]) { + text-underline-offset: 0.125em; + } + } + + :where(gem-link, dy-link, a[href]) { color: ${theme.primaryColor}; text-decoration: underline; - } - :where(gem-link, dy-link):where(:lang(zh), :lang(ja), :lang(kr)) { - text-underline-offset: 0.125em; + + &:where(:not([hidden])) { + display: inline-block; + } } code, kbd { diff --git a/packages/duoyun-ui/src/elements/space.ts b/packages/duoyun-ui/src/elements/space.ts index 9858a8a6..3e0e9a28 100644 --- a/packages/duoyun-ui/src/elements/space.ts +++ b/packages/duoyun-ui/src/elements/space.ts @@ -3,19 +3,19 @@ import { GemElement } from '@mantou/gem/lib/element'; import { createCSSSheet, css } from '@mantou/gem/lib/utils'; const style = createCSSSheet(css` - :where(dy-space:not([hidden])) { + :where(:scope:not([hidden])) { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 0.4em; } - :where(dy-space[orientation='vertical']) { + :where(:scope[orientation='vertical']) { flex-direction: column; } - :where(dy-space[size='small']) { + :where(:scope[size='small']) { gap: 0.2em; } - :where(dy-space[size='large']) { + :where(:scope[size='large']) { gap: 0.8em; } `); diff --git a/packages/duoyun-ui/src/lib/styles.ts b/packages/duoyun-ui/src/lib/styles.ts index 66c33519..5f561b59 100644 --- a/packages/duoyun-ui/src/lib/styles.ts +++ b/packages/duoyun-ui/src/lib/styles.ts @@ -17,29 +17,31 @@ export const focusStyle = createCSSSheet(css` } `); -// support `hidden` attribute export const blockContainer = createCSSSheet(css` - :host(:where(:not([hidden]))) { + :host(:where(:not([hidden]))), + :where(:scope:not([hidden])) { display: block; } `); -// support `hidden` attribute export const flexContainer = createCSSSheet(css` - :host(:where(:not([hidden]))) { + :host(:where(:not([hidden]))), + :where(:scope:not([hidden])) { display: flex; } `); export const contentsContainer = createCSSSheet(css` - :host(:where(:not([hidden]))) { + :host(:where(:not([hidden]))), + :where(:scope:not([hidden])) { display: contents; } `); export const noneTemplate = html` diff --git a/packages/duoyun-ui/src/lib/theme.ts b/packages/duoyun-ui/src/lib/theme.ts index ca2c9c80..0cdcc5fb 100644 --- a/packages/duoyun-ui/src/lib/theme.ts +++ b/packages/duoyun-ui/src/lib/theme.ts @@ -1,4 +1,4 @@ -import { getThemeStore, useTheme } from '@mantou/gem/helper/theme'; +import { getThemeStore, useTheme, Theme } from '@mantou/gem/helper/theme'; export function getSemanticColor(semantic?: string) { switch (semantic) { @@ -73,5 +73,5 @@ export const themeStore = getThemeStore(theme); export function extendTheme>(t: Partial & T) { updateTheme(t); - return [theme as typeof lightTheme & T, updateTheme as (tm: Partial & T) => void] as const; + return [theme as Theme, updateTheme as (tm: Partial & T) => void] as const; } diff --git a/packages/duoyun-ui/src/patterns/console.ts b/packages/duoyun-ui/src/patterns/console.ts index cb2972af..9061e58e 100644 --- a/packages/duoyun-ui/src/patterns/console.ts +++ b/packages/duoyun-ui/src/patterns/console.ts @@ -33,13 +33,13 @@ export type UserInfo = { profile?: string; }; -const rules = css` - dy-pat-console { +const style = createCSSSheet(css` + :scope { display: flex; color: ${theme.textColor}; background-color: ${theme.backgroundColor}; } - dy-pat-console .sidebar { + .sidebar { position: sticky; top: 0; flex-shrink: 0; @@ -51,28 +51,28 @@ const rules = css` background-color: ${theme.lightBackgroundColor}; padding: ${theme.gridGutter}; } - dy-pat-console .logo { + .logo { align-self: flex-start; height: 4em; /* logo must padding */ margin-inline-start: -0.2em; margin-block: 0em 2em; } - dy-pat-console .navigation { + .navigation { flex-grow: 1; margin-block-end: 3em; } - dy-pat-console .user-info { + .user-info { font-size: 0.875em; display: flex; align-items: center; gap: 0.5em; } - dy-pat-console .avatar, - dy-pat-console .menu { + .avatar, + .menu { flex-shrink: 0; } - dy-pat-console .user { + .user { min-width: 0; flex-grow: 1; flex-shrink: 1; @@ -80,40 +80,40 @@ const rules = css` flex-direction: column; line-height: 1.2; } - dy-pat-console .user:not([href]) { + .user:not([href]) { pointer-events: none; } - dy-pat-console .username { + .username { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - dy-pat-console .org { + .org { font-size: 0.75em; font-style: italic; color: ${theme.describeColor}; } - dy-pat-console .menu { + .menu { width: 1.5em; padding: 4px; border-radius: ${theme.normalRound}; } - dy-pat-console .menu:where(:hover, :state(active)) { + .menu:where(:hover, :state(active)) { background-color: ${theme.hoverBackgroundColor}; } - dy-pat-console .main-container { + .main-container { flex-grow: 1; min-width: 0; } - dy-pat-console .main { + .main { margin: auto; padding: calc(2 * ${theme.gridGutter}); max-width: 80em; } - dy-pat-console dy-light-route { + dy-light-route { display: contents; } - dy-pat-console[responsive] { + :scope[responsive] { @media ${mediaQuery.PHONE_LANDSCAPE} { .sidebar { width: 4.5em; @@ -131,44 +131,6 @@ const rules = css` } } } -`; - -// https://bugzilla.mozilla.org/show_bug.cgi?id=1830512 -const style = createCSSSheet( - 'CSSScopeRule' in window - ? ` - @scope (body) to (dy-light-route) { - ${rules} - } - ` - : rules, -); - -// 禁止橡皮条 -const consoleStyle = createCSSSheet(css` - ::selection, - ::target-text { - color: ${theme.backgroundColor}; - background: ${theme.primaryColor}; - } - ::highlight(search) { - color: ${theme.backgroundColor}; - background: ${theme.informativeColor}; - } - :where(:root) { - font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', - 'PingFang SC', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - height: 100%; - overflow: hidden; - } - :where(body) { - height: 100%; - overflow: auto; - overscroll-behavior: none; - margin: 0; - } `); /** @@ -176,7 +138,6 @@ const consoleStyle = createCSSSheet(css` */ @customElement('dy-pat-console') @adoptedStyle(style) -@adoptedStyle(consoleStyle) export class DyPatConsoleElement extends GemElement { @boolattribute keyboardAccess: boolean; @boolattribute screencastMode: boolean; @@ -259,6 +220,33 @@ export class DyPatConsoleElement extends GemElement { ${this.keyboardAccess ? html`` : ''} ${this.screencastMode ? html`` : ''} + + `; }; } diff --git a/packages/duoyun-ui/src/patterns/footer.ts b/packages/duoyun-ui/src/patterns/footer.ts index 3d01e972..baaa1c88 100644 --- a/packages/duoyun-ui/src/patterns/footer.ts +++ b/packages/duoyun-ui/src/patterns/footer.ts @@ -43,77 +43,78 @@ export type Languages = { }; const style = createCSSSheet(css` - dy-pat-footer { + :scope { display: block; background: ${theme.lightBackgroundColor}; line-height: 1.7; } - dy-pat-footer dy-link { + + dy-link { outline-offset: 0; display: inline-flex; align-items: center; gap: 0.5em; } - dy-pat-footer .outward { + .outward { width: 1.2em; } - dy-pat-footer footer { + footer { container-type: inline-size; max-width: 80em; margin: auto; } @container (width < 80em) { - dy-pat-footer :is(.social, .links, .terms-wrap) { - padding-inline: 2em; + :is(.social, .links, .terms-wrap) { + padding-inline: 1em; } - dy-pat-footer .outward { + .outward { display: none; } } - dy-pat-footer ul { + ul { display: contents; padding: 0; margin: 0; } - dy-pat-footer li { + li { list-style: none; } - dy-pat-footer :is(.terms, .social) { + :is(.terms, .social) { display: flex; flex-wrap: wrap; align-items: center; gap: 2em; } - dy-pat-footer :is(.terms-nav, .help, .social ul) { + :is(.terms-nav, .help, .social ul) { display: flex; flex-wrap: wrap; align-items: center; gap: 1.5em; } - dy-pat-footer :is(.links, .terms-nav, .help) :where(dy-link, select):not(:hover) { + :is(.links, .terms-nav, .help) :where(dy-link, select):not(:hover) { opacity: 0.65; } - dy-pat-footer .social { + .social { padding-block: 1.5em; } - dy-pat-footer .social li { + .social li { display: contents; } - dy-pat-footer h3 { + h3 { margin: 0; font-weight: 500; font-size: 1em; } - dy-pat-footer .icon { + .icon { width: 2em; } - dy-pat-footer .links { + .links { padding-block: 2.5em; display: flex; flex-wrap: wrap; gap: 1em; } - dy-pat-footer .column { + .column { width: 0; min-width: 10em; flex-grow: 1; @@ -121,22 +122,22 @@ const style = createCSSSheet(css` flex-direction: column; gap: 0.6em; } - dy-pat-footer h4 { + h4 { font-weight: 500; margin-block: 0 1em; font-size: 1em; } - dy-pat-footer .terms-wrap { + .terms-wrap { display: flex; gap: 2em; justify-content: space-between; flex-wrap: wrap; padding-block: 1.5em 3em; } - dy-pat-footer .logo { + .logo { height: 4em; } - dy-pat-footer .languages { + .languages { min-width: 10em; font-size: 1em; border: none; @@ -147,16 +148,18 @@ const style = createCSSSheet(css` `); const mobileStyle = createCSSSheet( + mediaQuery.PHONE, css` - dy-pat-footer .column { - width: 100%; - } - dy-pat-footer .column + .column { - border-block-start: 1px solid ${theme.borderColor}; - padding-block-start: 1em; + :scope { + .column { + width: 100%; + } + .column + .column { + border-block-start: 1px solid ${theme.borderColor}; + padding-block-start: 1em; + } } `, - mediaQuery.PHONE, ); /** diff --git a/packages/duoyun-ui/src/patterns/nav.ts b/packages/duoyun-ui/src/patterns/nav.ts index e4b506be..d6677e81 100644 --- a/packages/duoyun-ui/src/patterns/nav.ts +++ b/packages/duoyun-ui/src/patterns/nav.ts @@ -16,53 +16,53 @@ import '../elements/use'; export type { Links } from './footer'; const style = createCSSSheet(css` - dy-pat-nav { + :scope { display: flex; align-items: center; gap: 2em; background: ${theme.backgroundColor}; box-shadow: rgba(0, 0, 0, calc(${theme.maskAlpha} - 0.1)) 0px 0px 8px; } - dy-pat-nav, - dy-pat-nav .drawer-brand { + :scope, + .drawer-brand { padding: 0.6em 1em; } - dy-pat-nav .drawer-brand { + .drawer-brand { display: none; } - dy-pat-nav li { + li { list-style: none; } - dy-pat-nav dy-use:not(.menu) { + dy-use:not(.menu) { width: 1.2em; } - dy-pat-nav .menu { + .menu { display: none; } - dy-pat-nav :where(.brand, .navbar, .navbar-top-link, dy-link) { + :where(.brand, .navbar, .navbar-top-link, dy-link) { display: flex; align-items: center; } - dy-pat-nav .brand { + .brand { gap: 0.5em; } - dy-pat-nav .logo { + .logo { height: 2.6em; } - dy-pat-nav .name { + .name { font-size: 1.35em; opacity: 0.65; } - dy-pat-nav .navbar { + .navbar { gap: 0.5em; } - dy-pat-nav :where(.nav-list) { + :where(.nav-list) { display: contents; } - dy-pat-nav .navbar-item-wrap { + .navbar-item-wrap { position: relative; } - dy-pat-nav .navbar-top-link { + .navbar-top-link { cursor: pointer; gap: 0.3em; padding-inline: 0.5em; @@ -70,10 +70,10 @@ const style = createCSSSheet(css` border-radius: ${theme.normalRound}; opacity: 0.65; } - dy-pat-nav .navbar-item-wrap:hover .navbar-top-link { + .navbar-item-wrap:hover .navbar-top-link { background: ${theme.lightBackgroundColor}; } - dy-pat-nav .dropdown { + .dropdown { display: none; position: absolute; flex-direction: column; @@ -90,26 +90,27 @@ const style = createCSSSheet(css` filter: drop-shadow(${theme.borderColor} 0px 0px 1px) drop-shadow(rgba(0, 0, 0, calc(${theme.maskAlpha} - 0.1)) 0px 7px 10px); } - dy-pat-nav:where(:not(:state(switching))) :where(.navbar-item-wrap:where(:hover, :focus-within)) .dropdown { + :scope:where(:not(:state(switching))) :where(.navbar-item-wrap:where(:hover, :focus-within)) .dropdown { display: flex; } - dy-pat-nav .dropdown dy-link { + .dropdown dy-link { border-radius: ${theme.normalRound}; padding: 0.5em; gap: 0.3em; opacity: 0.65; } - dy-pat-nav .dropdown dy-link:hover { + .dropdown dy-link:hover { background: ${theme.lightBackgroundColor}; } `); const mobileStyle = createCSSSheet( + `${mediaQuery.PHONE}`, css` - dy-pat-nav { + :scope { gap: 1em; } - dy-pat-nav .drawer-brand { + .drawer-brand { display: block; position: sticky; top: 0; @@ -118,24 +119,24 @@ const mobileStyle = createCSSSheet( border-block-end: 1px solid ${theme.borderColor}; margin-block-end: 1em; } - dy-pat-nav .menu { + .menu { display: block; width: 1.5em; padding: 0.5em; margin: -0.5em; border-radius: 10em; } - dy-pat-nav .navbar:not(.open) { + .navbar:not(.open) { display: none; } - dy-pat-nav .navbar { + .navbar { position: fixed; z-index: ${theme.popupZIndex}; inset: 0; background: rgba(0, 0, 0, calc(${theme.maskAlpha})); align-items: stretch; } - dy-pat-nav .nav-list { + .nav-list { display: block; width: 20em; height: 100%; @@ -145,17 +146,17 @@ const mobileStyle = createCSSSheet( overflow: auto; overscroll-behavior: none; } - dy-pat-nav .nav-list > li { + .nav-list > li { padding-inline: 1em; } - dy-pat-nav .nav-list > li:last-of-type { + .nav-list > li:last-of-type { margin-block-end: 3em; } - dy-pat-nav .open-dropdown + .dropdown { + .open-dropdown + .dropdown { display: block; padding-inline-start: 2em; } - dy-pat-nav .dropdown { + .dropdown { position: relative; display: none; width: 100%; @@ -164,7 +165,7 @@ const mobileStyle = createCSSSheet( min-width: auto; filter: none; } - dy-pat-nav .dropdown::before { + .dropdown::before { position: absolute; content: ''; width: 1px; @@ -174,7 +175,6 @@ const mobileStyle = createCSSSheet( background: ${theme.borderColor}; } `, - `${mediaQuery.PHONE}`, ); type State = { diff --git a/packages/gem-book/src/plugins/api.ts b/packages/gem-book/src/plugins/api.ts index f6e00c63..0d3c767e 100644 --- a/packages/gem-book/src/plugins/api.ts +++ b/packages/gem-book/src/plugins/api.ts @@ -12,18 +12,16 @@ customElements.whenDefined('gem-book').then(({ GemBookPluginElement }: typeof Ge const { html, customElement, attribute, numattribute, createCSSSheet, css, adoptedStyle, shadow } = Gem; const styles = createCSSSheet(css` - gbp-api table { + table { tr td:first-of-type { white-space: nowrap; } } - gbp-api { - .loading { - opacity: 0.5; - } - .error { - color: ${theme.cautionColor}; - } + .loading { + opacity: 0.5; + } + .error { + color: ${theme.cautionColor}; } `); diff --git a/packages/gem-devtools/src/theme.ts b/packages/gem-devtools/src/theme.ts index 1d03874e..0007b47c 100644 --- a/packages/gem-devtools/src/theme.ts +++ b/packages/gem-devtools/src/theme.ts @@ -1,5 +1,5 @@ import { devtools } from 'webextension-polyfill'; -import { createTheme, updateTheme } from '@mantou/gem/helper/theme'; +import { useTheme } from '@mantou/gem/helper/theme'; const lightTheme = { backgroundColorRGB: '255,255,255', @@ -18,11 +18,11 @@ const darkTheme = { textColorRGB: '225,225,225', }; -export const theme = createTheme(lightTheme); +export const [theme, updateTheme] = useTheme(lightTheme); const update = () => { const isDark = devtools.panels.themeName === 'dark'; - updateTheme(theme, isDark ? darkTheme : lightTheme); + updateTheme(isDark ? darkTheme : lightTheme); }; update(); diff --git a/packages/gem-examples/src/scope/index.ts b/packages/gem-examples/src/scope/index.ts index 7c5e4fc3..5b84217f 100644 --- a/packages/gem-examples/src/scope/index.ts +++ b/packages/gem-examples/src/scope/index.ts @@ -5,14 +5,15 @@ import { GemElement, adoptedStyle, createCSSSheet, css, customElement, html, ren import '../elements/layout'; +@customElement('other-element') +class _OtherElement extends GemElement {} + const style = createCSSSheet(css` - @scope (app-root) to ([ref]) { - :scope { - font-style: italic; - } - p { - text-decoration: underline; - } + :scope { + font-style: italic; + } + p { + text-decoration: underline; } `); @@ -24,9 +25,9 @@ export class App extends GemElement { Text

Header

Content

- +

Other Content

-
+

Content

diff --git a/packages/gem-examples/src/styled/index.ts b/packages/gem-examples/src/styled/index.ts index 95ff124e..d3643ec0 100644 --- a/packages/gem-examples/src/styled/index.ts +++ b/packages/gem-examples/src/styled/index.ts @@ -1,29 +1,34 @@ -import { GemElement, html, styled, createCSSSheet, render, adoptedStyle, customElement } from '@mantou/gem'; +import { GemElement, html, styled, createCSSSheet, render, adoptedStyle, customElement, SheetToken } from '@mantou/gem'; import '../elements/layout'; const styles = createCSSSheet({ - h1: styled.class` - color: yellow; + $: styled.class` + font-style: italic; + `, + div: styled.class` + color: blue; &:hover { color: red; } `, - h2: styled.id` - background: yellow; - &:hover { - background: red; - } +}); + +const styles2 = createCSSSheet({ + [styles.div]: styled.class` + font-weight: bold; + font-size: 2em; `, }); @adoptedStyle(styles) +@adoptedStyle(styles2) @customElement('app-root') export class App extends GemElement { render() { return html` -

Header 1

-

Header 2

+
Header 1
+ ${[styles, styles2].map((styles) => html`
${styles[SheetToken].getStyle(this).cssRules[0].cssText}
`)} `; } } diff --git a/packages/gem-examples/src/theme/index.ts b/packages/gem-examples/src/theme/index.ts index 9f952763..b6ef01b7 100644 --- a/packages/gem-examples/src/theme/index.ts +++ b/packages/gem-examples/src/theme/index.ts @@ -1,53 +1,64 @@ -import { GemElement, html, render, customElement, connectStore, createCSSSheet, css, adoptedStyle } from '@mantou/gem'; -import { createTheme, getThemeStore, updateTheme } from '@mantou/gem/helper/theme'; +import { + GemElement, + html, + render, + customElement, + connectStore, + createCSSSheet, + css, + adoptedStyle, + styleMap, +} from '@mantou/gem'; +import { useTheme, getThemeStore, useScopedTheme } from '@mantou/gem/helper/theme'; import { mediaQuery } from '@mantou/gem/helper/mediaquery'; import '../elements/layout'; -const theme = createTheme({ +const [scopedTheme, updateScopedTheme] = useScopedTheme({ // 支持动态修改不透明度 color: '0, 0, 0', - primaryColor: '#eee', + borderColor: '#eee', }); -const themeStore = getThemeStore(theme); +const themeStore = getThemeStore(scopedTheme); -const printTheme = createTheme({ - primaryColor: 'yellow', +const [printTheme, updatePrintTheme] = useTheme({ + borderColor: 'yellow', }); document.onclick = () => { - updateTheme(theme, { - primaryColor: Math.random() > 0.5 ? 'red' : 'blue', + updateScopedTheme({ + borderColor: Math.random() > 0.5 ? 'red' : 'blue', }); - updateTheme(printTheme, { - primaryColor: Math.random() > 0.5 ? 'gray' : 'white', + updatePrintTheme({ + borderColor: Math.random() > 0.5 ? 'gray' : 'white', }); }; const style = createCSSSheet(css` div { - color: rgba(${theme.color}, 0.5); - border: 2px solid ${theme.primaryColor}; + color: rgba(${scopedTheme.color}, 0.5); + border: 2px solid ${scopedTheme.borderColor}; } `); -const style1 = createCSSSheet( +const printStyle = createCSSSheet( + mediaQuery.PRINT, css` div { - border: 2px solid ${printTheme.primaryColor}; + border: 2px solid ${printTheme.borderColor}; } `, - mediaQuery.PRINT, ); @customElement('app-root') @connectStore(themeStore) -@adoptedStyle(style1) +@adoptedStyle(scopedTheme) +// @adoptedStyle(printStyle) @adoptedStyle(style) export class App extends GemElement { render() { - return html`
color: ${themeStore.primaryColor}
`; + return html`
color: ${themeStore.borderColor}
`; } } @@ -55,6 +66,7 @@ render( html` +
outer scope, needn't apply style
`, document.body, diff --git a/packages/gem/src/helper/theme.ts b/packages/gem/src/helper/theme.ts index 2456d69f..9ba849f4 100644 --- a/packages/gem/src/helper/theme.ts +++ b/packages/gem/src/helper/theme.ts @@ -1,71 +1,60 @@ -import { connect, createStore, updateStore, Store } from '../lib/store'; -import { camelToKebabCase, randomStr } from '../lib/utils'; +import { connect, Store, useStore } from '../lib/store'; +import { camelToKebabCase, randomStr, Sheet, SheetToken, GemCSSSheet } from '../lib/utils'; -type SomeType = { - [P in keyof T]: string; -}; +export type Theme = Sheet; const themeStoreMap = new WeakMap(); +const themePropsMap = new WeakMap(); /**获取主题原始值 */ -export function getThemeStore(theme: SomeType) { +export function getThemeStore(theme: Theme) { return themeStoreMap.get(theme) as Store; } -const themePropsMap = new WeakMap(); - /**获取 css 变量名 */ -export function getThemeProps(theme: SomeType) { - return themePropsMap.get(theme) as SomeType; +export function getThemeProps(theme: Theme) { + return themePropsMap.get(theme) as Theme; } -const setThemeFnMap = new WeakMap(); - /** - * 创建主题,插入 `document.head` - * https://github.com/mantou132/gem/issues/33 - * - * @example - * createTheme({ - * primaryColor: '#eee', - * }); + * 用于 `@adoptedStyle(theme)`,类似 `createCSSSheet` */ -export function createTheme>(themeObj: T) { +export function useScopedTheme>(themeObj: T) { const salt = randomStr(); - const style = new CSSStyleSheet(); - const store = createStore(themeObj); - const theme: Record = {}; + const styleSheet = new GemCSSSheet(); + const [store, updateStore] = useStore(themeObj); const props: Record = {}; + const theme: any = { [SheetToken]: styleSheet }; themePropsMap.set(theme, props); themeStoreMap.set(theme, store); - const setTheme = () => + + const updateContent = () => { + let rules = ''; Object.keys(store).forEach((key) => { - if (props[key]) return; - props[key] = `--${camelToKebabCase(key)}-${salt}`; - theme[key] = `var(${props[key]})`; + if (!props[key]) { + // 保留已经是 css 变量的 key,支持覆盖修改 + props[key] = key.startsWith('-') ? key : `--${camelToKebabCase(key)}-${salt}`; + theme[key] = `var(${props[key]})`; + } + rules += `${props[key]}:${store[key]};`; }); - setThemeFnMap.set(theme, setTheme); - setTheme(); - const getStyle = () => - `:root, :host {${Object.keys(store).reduce((prev, key) => prev + `${props[key]}:${store[key]};`, '')}}`; - const replace = () => style.replaceSync(getStyle()); - connect(store, replace); - replace(); - document.adoptedStyleSheets.push(style); - return theme as SomeType; + styleSheet.setContent(`:scope,:host{${rules}}`); + }; + updateContent(); + connect(store, () => { + updateContent(); + styleSheet.updateStyle(); + }); + return [theme as Theme, updateStore] as const; } -/** - * 更新主题 - * @param theme 主题 - * @param newThemeObj 新主题 - */ -export function updateTheme>(theme: SomeType, newThemeObj: Partial) { - updateStore(getThemeStore(theme), newThemeObj); - setThemeFnMap.get(theme)(); +export function useTheme>(themeObj: T) { + const result = useScopedTheme(themeObj); + document.adoptedStyleSheets.push(result[0][SheetToken].getStyle()); + return result; } -export function useTheme>(themeObj: T) { - const theme = createTheme(themeObj); - return [theme, (newThemeObj: Partial) => updateTheme(theme, newThemeObj)] as const; +export function useOverrideTheme, K extends T>(origin: Theme, themeObj: T) { + const props = getThemeProps(origin); + return useScopedTheme(Object.fromEntries(Object.entries(themeObj).map(([k, v]) => [props[k], v]))); } diff --git a/packages/gem/src/lib/element.ts b/packages/gem/src/lib/element.ts index cf4e4475..2b18cd4d 100644 --- a/packages/gem/src/lib/element.ts +++ b/packages/gem/src/lib/element.ts @@ -167,7 +167,7 @@ export abstract class GemElement> extends HTMLEl Object.assign(this.internals, aria); - const sheets = adoptedStyleSheets?.map((item) => item[SheetToken] || item) || []; + const sheets = adoptedStyleSheets?.map((item) => item[SheetToken].getStyle(this)) || []; if (this.shadowRoot) { this.shadowRoot.adoptedStyleSheets = sheets; } else { diff --git a/packages/gem/src/lib/utils.ts b/packages/gem/src/lib/utils.ts index af17852d..c91cca9f 100644 --- a/packages/gem/src/lib/utils.ts +++ b/packages/gem/src/lib/utils.ts @@ -235,55 +235,89 @@ export function raw(arr: TemplateStringsArray, ...args: any[]) { } // 写 css 文本,在 CSSStyleSheet 中使用 -export function css(arr: TemplateStringsArray, ...args: any[]) { - return raw(arr, ...args); -} +export const css = raw; // 跨多个 gem 工作 export const SheetToken = Symbol.for('gem@sheetToken'); -export type Sheet = { - [P in keyof T]: P; -} & { [SheetToken]: CSSStyleSheet }; +export class GemCSSSheet { + #content = ''; + #media = ''; + constructor(media = '') { + this.#media = media; + } + setContent(v: string) { + this.#content = v; + } -export type StyledType = 'id' | 'class' | 'keyframes'; -export interface StyledValueObject { - styledContent: string; - type: StyledType; -} -export interface StyledKeyValuePair { - [key: string]: StyledValueObject; + // 不需要 GC + #record = new Map(); + #applyd = new Map(); + getStyle(host?: HTMLElement) { + const isLight = host && !host.shadowRoot; + + // 对同一类 dom 只使用同一个样式表 + const key = isLight ? host.constructor : this; + if (!this.#record.has(key)) { + const sheet = new CSSStyleSheet({ media: this.#media }); + this.#record.set(key, sheet); + } + + const sheet = this.#record.get(key)!; + + // 只执行一次 + if (!this.#applyd.has(sheet)) { + let style = this.#content; + let scope = ''; + if (isLight) { + scope = `@scope (${host.tagName}) to ([data-style-scope])`; + style = `${scope}{${style}}`; + } + sheet.replaceSync(style); + this.#applyd.set(sheet, scope); + } + + return sheet; + } + + // 一般用于主题更新 + updateStyle() { + this.#applyd.forEach((scope, sheet) => { + sheet.replaceSync(scope ? `${scope}{${this.#content}}` : this.#content); + }); + } } +export type Sheet = { + [P in keyof T]: P; +} & { [SheetToken]: GemCSSSheet }; + /** * - * 创建 style sheet 用于 `adoptedStyleSheets`,不支持样式更新,只支持自定义 CSS 属性 - * @param rules string | Record - * @param media string 媒体查询 + * 创建 style sheet 用于 `@adoptedStyle`,不支持样式更新,只支持自定义 CSS 属性 */ -export function createCSSSheet(rules: T | string, media?: string): Sheet { - const styleSheet = new CSSStyleSheet({ media }); - const sheet: any = {}; +export function createCSSSheet>(media: string, rules: T | string): Sheet; +export function createCSSSheet>(rules: T | string): Sheet; +export function createCSSSheet>( + mediaOrRules: T | string, + rulesValue?: T | string, +): Sheet { + const media = rulesValue ? (mediaOrRules as string) : ''; + const rules = rulesValue || mediaOrRules; + const styleSheet = new GemCSSSheet(media); + const sheet: any = { [SheetToken]: styleSheet }; let style = ''; if (typeof rules === 'string') { style = rules; } else { - Object.keys(rules).forEach((key: keyof T) => { - sheet[key] = `${key as string}-${randomStr()}`; - switch (rules[key].type) { - case 'class': - style += `.${sheet[key]} {${rules[key].styledContent}}`; - break; - case 'id': - style += `#${sheet[key]} {${rules[key].styledContent}}`; - break; - default: - style += `@keyframes ${key as string} {${rules[key].styledContent}}`; - } + Object.keys(rules).forEach((key) => { + const isScope = key === '$'; + // 对于已经有 `-` 的保留原始 key,支持覆盖修改 + sheet[key] = isScope || key.includes('-') ? key : `${key}-${randomStr()}`; + style += `${isScope ? ':scope,:host' : `.${sheet[key]}`} {${rules[key]}}`; }); } - styleSheet.replaceSync(style); - sheet[SheetToken] = styleSheet; + styleSheet.setContent(style); return sheet as Sheet; } @@ -305,17 +339,7 @@ export function randomStr(len = 5): string { // } // `, // }); -export const styled = { - class: (arr: TemplateStringsArray, ...args: any[]): StyledValueObject => { - return { styledContent: raw(arr, ...args), type: 'class' }; - }, - id: (arr: TemplateStringsArray, ...args: any[]): StyledValueObject => { - return { styledContent: raw(arr, ...args), type: 'id' }; - }, - keyframes: (arr: TemplateStringsArray, ...args: any[]): StyledValueObject => { - return { styledContent: raw(arr, ...args), type: 'keyframes' }; - }, -}; +export const styled = { class: raw }; export function camelToKebabCase(str: string) { return str.replace(/[A-Z]/g, ($1: string) => '-' + $1.toLowerCase()); diff --git a/packages/gem/src/test/gem-element/advance.test.ts b/packages/gem/src/test/gem-element/advance.test.ts index aa140d5b..684bc320 100644 --- a/packages/gem/src/test/gem-element/advance.test.ts +++ b/packages/gem/src/test/gem-element/advance.test.ts @@ -55,7 +55,7 @@ describe('异步 gem element 测试', () => { }); const lightStyle = createCSSSheet(css` - body { + div { font-size: 18.1px; } `); @@ -69,15 +69,20 @@ class LightGemDemo extends GemElement { } describe('没有 Shadow DOM 的 gem 元素', () => { it('渲染没有 Shadow DOM 的 gem 元素', async () => { + const el0: LightGemDemo = await fixture(html`
`); const el1: LightGemDemo = await fixture(html``); const el2: LightGemDemo = await fixture(html``); expect(el1.shadowRoot).to.equal(null); expect(el1.innerHTML.includes('hi')).to.equal(true); - expect(getComputedStyle(document.body).fontSize).to.equal('18.1px'); + expect(getComputedStyle(el0).fontSize).not.to.equal('18.1px'); + expect(getComputedStyle(el2.firstElementChild!).fontSize).to.equal('18.1px'); el1.remove(); - expect(getComputedStyle(document.body).fontSize).to.equal('18.1px'); + await Promise.resolve(); + expect(getComputedStyle(el2.firstElementChild!).fontSize).to.equal('18.1px'); el2.remove(); - expect(getComputedStyle(document.body).fontSize).not.to.equal('18.1px'); + // 样式在队列末尾执行 + await Promise.resolve(); + expect(getComputedStyle(el0).fontSize).not.to.equal('18.1px'); }); }); diff --git a/packages/gem/src/test/utils.test.ts b/packages/gem/src/test/utils.test.ts index baebba25..bc50ad48 100644 --- a/packages/gem/src/test/utils.test.ts +++ b/packages/gem/src/test/utils.test.ts @@ -84,13 +84,17 @@ describe('utils 测试', () => { }); it('createCSSSheet', () => { const cssSheet = createCSSSheet(css` - body { + div { background: red; } `); - const rules = cssSheet[SheetToken].cssRules; - expect(rules.item(0).selectorText).to.equal('body'); + const rules = cssSheet[SheetToken].getStyle().cssRules; + expect(rules.item(0).selectorText).to.equal('div'); expect(rules.item(0).style.background).to.equal('red'); + const bodyStyleSheet = cssSheet[SheetToken].getStyle(document.body); + expect(bodyStyleSheet.cssRules.item(0).cssText.startsWith('@scope (body)')).to.true; + expect(bodyStyleSheet).to.equal(bodyStyleSheet); + expect(bodyStyleSheet).not.to.equal(cssSheet[SheetToken].getStyle(document.documentElement)); }); it('raw/css', () => { const title: any = undefined; @@ -99,30 +103,21 @@ describe('utils 测试', () => { }); it('styled', () => { const cssSheet = createCSSSheet({ + $: styled.class``, scroll: styled.class` background: red; &:hover * { background: blue; } `, - wrap: styled.id` - position: fixed; - animation: 3s infinite alternate slideIn; - `, - slideIn: styled.keyframes` - from { - transform: translateX(0%); - } - `, }); expect(cssSheet.scroll.startsWith('scroll')).to.true; - const rules = cssSheet[SheetToken].cssRules; - expect(rules.item(0).selectorText.startsWith('.scroll')).to.true; - expect(rules.item(0).style.background).to.equal('red'); - expect(rules.item(0).cssRules.item(0).selectorText).to.equal('&:hover *'); - expect(rules.item(0).cssRules.item(0).style.background).to.equal('blue'); - expect(rules.item(1).selectorText.startsWith('#wrap')).to.true; - expect((rules.item(2) as any).name).to.equal('slideIn'); + const rules = cssSheet[SheetToken].getStyle().cssRules; + expect(rules.item(0).selectorText).to.equal(':scope, :host'); + expect(rules.item(1).selectorText.startsWith('.scroll')).to.true; + expect(rules.item(1).style.background).to.equal('red'); + expect(rules.item(1).cssRules.item(0).selectorText).to.equal('&:hover *'); + expect(rules.item(1).cssRules.item(0).style.background).to.equal('blue'); }); it('styleMap/classMap/exportPartsMap', () => { expect(styleMap({ '--x': '1px', fontSize: '14px', content: `'*'` })).to.equal(