jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Components · Phần 6 — CSS encapsulation, theming và shadow parts

Thiết kế ranh giới CSS cho Web Components bằng inheritance, custom properties, :host, ::slotted, ::part, exportparts và constructable stylesheets có fallback.

Shadow DOM giúp <kb-task-column> không vỡ chỉ vì trang chủ có rule h2 { margin: 3rem }. Nhưng “CSS bị cô lập” không có nghĩa là mọi giá trị đều bị bịt kín. Font, màu chữ và CSS custom properties vẫn có thể kế thừa qua host; chính component cũng cần cho consumer đổi màu nền, khoảng cách hoặc bo góc.

Bài toán production không phải khóa mọi thứ. Ta cần một theming contract:

  • layout và invariant nội bộ vẫn thuộc component;
  • consumer đổi được design token ổn định;
  • một vài vùng được phép style sâu hơn qua part;
  • chi tiết triển khai như .header > div:first-child không trở thành API công khai.

1. Ranh giới CSS thật sự chặn điều gì?

Với shadow root:

<kb-task-column>
  #shadow-root
  <section class="surface">
    <h2>Đang làm</h2>
  </section>
</kb-task-column>

Rule từ document không chọn được .surface hoặc h2 bên trong:

/* Có thể style chính host. */
kb-task-column {
  inline-size: 20rem;
}

/* Không xuyên vào shadow tree. */
kb-task-column .surface {
  border: 0;
}
kb-task-column h2 {
  font-size: 4rem;
}

Chiều ngược lại cũng vậy: .surface { ... } trong shadow root không chọn element cùng class ở document. ID và class nội bộ vì thế có thể đổi mà không va chạm CSS trang.

Tuy nhiên, CSS inheritance vẫn hoạt động. Nếu host nhận color, font-family, direction hoặc một custom property, descendant trong shadow tree có thể kế thừa giá trị đó:

body {
  color: #172033;
  font-family: Inter, system-ui, sans-serif;
}

kb-task-column {
  --kb-column-surface: #f8fafc;
}

Nếu component cần một invariant, hãy đặt nó rõ ràng. Nếu cần thích nghi với trang, hãy cố ý dùng inherit hoặc để thuộc tính kế thừa tự nhiên. Encapsulation là ranh giới selector, không phải reset toàn bộ cascade.

2. Style host bằng :host:host()

Stylesheet bên trong không thể viết selector tên thẻ một cách đáng tin cậy để chọn host. Ta có :host:

:host {
  display: block;
  min-inline-size: 16rem;
  color: inherit;
}

:host([hidden]) {
  display: none;
}

:host([density='compact']) .tasks {
  gap: 0.375rem;
}

:host(.drag-target) .surface {
  outline: 2px dashed var(--kb-accent, #2563eb);
}

:host chọn host hiện tại. :host(selector) chỉ khớp khi host đồng thời khớp selector, ví dụ attribute state hoặc class integration. Attribute như density="compact" nên là public state đã được tài liệu hóa; đừng biến mọi class nội bộ thành tùy chọn ngầm.

CSS ngoài vẫn được phép style host vì host thuộc document. Đây là chủ ý: consumer phải kiểm soát vị trí của component trong grid hoặc flex layout. Component nên kiểm soát cấu trúc bên trong, không áp đặt nơi nó được đặt.

3. Custom properties là kênh theming mặc định

Custom properties kế thừa qua shadow boundary, nên chúng phù hợp với giá trị thiết kế nhỏ và ổn định:

/* Bên trong shadow root */
.surface {
  background: var(--kb-column-surface, #ffffff);
  border: 1px solid var(--kb-column-border, #cbd5e1);
  border-radius: var(--kb-column-radius, 0.75rem);
  padding: var(--kb-column-padding, 1rem);
}

.tasks {
  gap: var(--kb-column-gap, 0.75rem);
}

Consumer chỉ đặt token cần đổi:

.board[data-theme='midnight'] kb-task-column {
  --kb-column-surface: #111827;
  --kb-column-border: #334155;
  --kb-column-radius: 1rem;
  --kb-column-gap: 0.625rem;
  color: #f8fafc;
}

Ưu tiên fallback ngay tại nơi dùng var(). Nếu component khai báo cứng --kb-column-surface trên một descendant nội bộ, giá trị từ host có thể bị che bởi declaration gần hơn.

Tên token nên mô tả vai trò (surface, border, gap) thay vì giá trị (gray-100) hoặc cấu trúc DOM (header-div-background). Mỗi token cần tài liệu: kiểu giá trị, fallback, phạm vi và trạng thái được hỗ trợ. Xóa hoặc đổi nghĩa token đã công khai là breaking change.

Custom property phù hợp khi consumer thay một giá trị. Khi consumer cần cung cấp cả tập declaration như shadow, gradient, typography hoặc trạng thái hover, ::part() là van mở có chủ đích hơn.

4. ::slotted() chỉ chạm node được gán trực tiếp

Từ trong shadow root, component có thể style element light DOM được gán vào slot:

::slotted([slot='heading']) {
  font: 700 1rem/1.3 system-ui;
}

::slotted(kb-task-card) {
  display: block;
}

Giới hạn quan trọng: ::slotted() chỉ chọn element được gán trực tiếp, không xuyên xuống descendant của nó.

/* Selector không hợp lệ: đối số ::slotted() không nhận combinator. */
::slotted(kb-task-card button) {
  color: red;
}

Không chỉ “không match”: selector trên không hợp lệ vì đối số của ::slotted() phải là compound selector. Đó cũng là ranh giới quyền sở hữu đúng: <kb-task-column> có thể bố trí card như một đơn vị, nhưng không nên sửa nút nội bộ của <kb-task-card>. Card phải tự cung cấp token hoặc part của chính nó.

Ngoài ra, ::slotted() không chọn text node. Nếu heading cần style ổn định, contract nên yêu cầu một element như <span slot="heading">…</span> thay vì text trần.

5. Công khai vùng style bằng part

Component đánh dấu các node được phép tùy biến:

<section class="surface" part="surface">
  <header class="header" part="header">
    <h2 part="heading"><slot name="heading"></slot></h2>
    <span class="count" part="count"></span>
  </header>
  <div class="tasks" part="task-list"><slot></slot></div>
</section>

Consumer chọn chúng từ ngoài bằng ::part():

kb-task-column::part(surface) {
  box-shadow: 0 12px 30px rgb(15 23 42 / 12%);
}

kb-task-column::part(heading) {
  letter-spacing: 0.02em;
}

part không làm lộ DOM cho JavaScript và cũng không cho phép selector tùy ý xuyên shadow root. Consumer chỉ thấy các tên part mà component công bố. Một node có thể mang nhiều token, ví dụ part="control danger-control".

Đừng gắn part cho mọi element. Mỗi part là một cam kết versioning: nếu xóa node hoặc đổi tên, CSS consumer có thể vỡ. Chỉ expose vùng có use case thực tế; giữ layout-critical declaration ở nội bộ nếu override chúng có thể phá hành vi.

Forward part qua component lồng nhau

Giả sử <kb-task-board> có shadow root và render column bên trong. Shadow boundary thứ hai sẽ chặn part của column. exportparts forward chúng qua boundary:

<!-- Nằm trong shadow root của kb-task-board -->
<kb-task-column
  exportparts="surface:column-surface, heading:column-heading"
></kb-task-column>

Consumer của board dùng tên đã map:

kb-task-board::part(column-surface) {
  border-color: color-mix(in srgb, currentColor 25%, transparent);
}

kb-task-board::part(column-heading) {
  text-transform: uppercase;
}

Nếu giữ nguyên tên, viết exportparts="surface, heading". Việc map tên giúp board cung cấp namespace có nghĩa và không vô tình chuyển tiếp mọi part của child.


6. Một stylesheet dùng chung cho mọi instance

Nhúng <style> trong template là fallback đơn giản và đáng tin cậy. Khi có nhiều instance, constructable stylesheet cho phép parse CSS một lần rồi chia sẻ cùng object:

const columnCSS = `
  :host { display: block; color: inherit; }
  :host([hidden]) { display: none; }

  .surface {
    box-sizing: border-box;
    background: var(--kb-column-surface, #fff);
    border: 1px solid var(--kb-column-border, #cbd5e1);
    border-radius: var(--kb-column-radius, 0.75rem);
    padding: var(--kb-column-padding, 1rem);
  }

  .tasks {
    display: grid;
    gap: var(--kb-column-gap, 0.75rem);
  }

  :host([density='compact']) .surface { padding: 0.625rem; }
  :host([density='compact']) .tasks { gap: 0.5rem; }

  ::slotted(kb-task-card) { min-inline-size: 0; }
`;

const canAdopt =
  'adoptedStyleSheets' in ShadowRoot.prototype &&
  'CSSStyleSheet' in globalThis &&
  'replaceSync' in CSSStyleSheet.prototype;

const sharedSheet = canAdopt ? new CSSStyleSheet() : null;
sharedSheet?.replaceSync(columnCSS);

function installStyles(root) {
  if (sharedSheet) {
    root.adoptedStyleSheets = [...root.adoptedStyleSheets, sharedSheet];
    return;
  }

  const style = document.createElement('style');
  style.textContent = columnCSS;
  root.prepend(style);
}

class KbTaskColumn extends HTMLElement {
  #taskSlot;
  #count;
  #empty;

  #handleSlotChange = () => this.#syncTaskCount();

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <section class="surface" part="surface">
        <header part="header">
          <h2 part="heading">
            <slot name="heading">Cột chưa đặt tên</slot>
          </h2>
          <span part="count" aria-live="polite"></span>
          <slot name="actions"></slot>
        </header>
        <div class="tasks" part="task-list">
          <slot></slot>
          <p class="empty" hidden>Chưa có công việc.</p>
        </div>
      </section>
    `;
    installStyles(root);

    this.#taskSlot = root.querySelector('.tasks > slot');
    this.#count = root.querySelector('[part="count"]');
    this.#empty = root.querySelector('.empty');
  }

  connectedCallback() {
    this.#taskSlot.addEventListener('slotchange', this.#handleSlotChange);
    this.#syncTaskCount();
  }

  disconnectedCallback() {
    this.#taskSlot.removeEventListener('slotchange', this.#handleSlotChange);
  }

  #syncTaskCount() {
    const taskCount = this.#taskSlot
      .assignedElements({ flatten: true })
      .filter((element) => element.matches('kb-task-card')).length;
    const label = `${taskCount} task`;
    if (this.#count.textContent !== label) this.#count.textContent = label;
    this.#empty.hidden = taskCount > 0;
  }
}

customElements.define('kb-task-column', KbTaskColumn);

Ví dụ này giữ nguyên behavior slotchange/đếm task từ Phần 5; thay đổi chỉ nằm ở cách cài stylesheet và các styling hook. Khi thay chiến lược CSS, đừng vô tình làm mất contract hành vi đã có.

Một constructed stylesheet có thể được nhiều shadow root trong cùng document sử dụng. Sửa object sheet sẽ ảnh hưởng mọi nơi đã adopt nó, nên hãy coi shared sheet là immutable trong application code. Fallback <style> giữ component hoạt động ở môi trường không hỗ trợ hoặc môi trường test tối giản.

Nếu component được tạo trong document/realm khác, ví dụ một số trường hợp iframe, stylesheet phải hợp lệ đối với document chứa shadow root. Đừng che mọi exception bằng try/catch; hãy có test tích hợp cho môi trường mà package cam kết hỗ trợ.

7. Chốt theming contract cho Mini Kanban

Contract nhỏ nhưng đủ dùng:

APIMục đích
--kb-column-surfacemàu nền cột
--kb-column-bordermàu border
--kb-column-radiusbán kính góc
--kb-column-paddingkhoảng đệm bên trong
--kb-column-gapkhoảng cách giữa task
surface, heading, count, task-listvùng style nâng cao qua ::part()
density="compact"state layout có semantics rõ

Token xử lý theme phổ biến; part là escape hatch. Consumer không cần biết .surface, .tasks hay thứ tự wrapper bên trong, nên component có thể refactor mà không phá contract.

8. Failure modes thường gặp

  • Nghĩ Shadow DOM chặn inheritance: màu chữ, font và custom properties vẫn có thể đi qua host. Kiểm tra component trong nhiều context, không chỉ trên trang trắng.
  • Đặt default token quá gần nơi dùng: declaration trên descendant nội bộ có thể che token được kế thừa. Dùng var(--token, fallback) tại property thật.
  • Dùng ::slotted() như selector xuyên sâu: nó chỉ chọn node được gán trực tiếp, không chọn descendant hoặc text node.
  • Expose mọi node bằng part: bạn biến cấu trúc triển khai thành public API và tự khóa khả năng refactor.
  • Forward toàn bộ part không chủ đích: dùng danh sách exportparts nhỏ và map tên khi component lồng nhau.
  • Parse cùng CSS cho từng instance: với component xuất hiện dày đặc, chia sẻ constructed stylesheet; vẫn giữ fallback phù hợp với support matrix.
  • Theme làm mất tương phản hoặc focus indicator: theming contract cần demo trạng thái hover, focus, disabled và kiểm tra contrast, không chỉ ảnh chụp trạng thái mặc định.

9. Bài tập

  1. Cơ bản: thêm theme high-contrast chỉ bằng custom properties, không dùng selector vào shadow tree.
  2. Mở rộng: bọc <kb-task-column> trong <kb-task-board> có shadow root và forward duy nhất surface cùng heading dưới tên mới.
  3. Thử thách: viết contract test tạo hai column, xác nhận chúng cùng adopt một CSSStyleSheet, rồi kiểm tra fallback <style> bằng cách tách installStyles() thành hàm có feature flag.

Cốt lõi cần nhớ

  • Shadow DOM cô lập selector, nhưng inheritance và custom properties vẫn đi qua host.
  • :host style host từ bên trong; :host(selector) nối public state của host với style nội bộ.
  • Custom properties là kênh theming ưu tiên; ::part() dành cho vùng cần cả tập declaration.
  • ::slotted() chỉ chạm element được gán trực tiếp.
  • exportparts forward có chọn lọc part qua shadow boundary lồng nhau.
  • Constructable stylesheet giảm parse lặp lại; một <style> fallback vẫn là phương án đơn giản và rõ ràng.

Phần 7 chuyển từ CSS sang tương tác: event nào vượt shadow boundary, vì sao event.target bị retarget, focus đi đâu, và một task card cần semantics cùng keyboard contract ra sao để thật sự dùng được.

Nguồn chính thức