Skip to content

Instantly share code, notes, and snippets.

@xiaohui-zhangxh
Last active February 28, 2025 02:51
Show Gist options
  • Save xiaohui-zhangxh/9d0de40062e1de36423416d1da9f146c to your computer and use it in GitHub Desktop.
Save xiaohui-zhangxh/9d0de40062e1de36423416d1da9f146c to your computer and use it in GitHub Desktop.
Responsive HTML Attributes like TailwindCSS (StimulusJS Controller)
import { Controller } from "@hotwired/stimulus";
/**
* 响应式属性控制器 / Responsive Attribute Controller
*
* 这个控制器用于根据屏幕尺寸动态改变元素的属性值。
* This controller is used to dynamically change element attributes based on screen size.
*
* ## 使用方法 / Usage
*
* 1. 在父元素上添加控制器 / Add controller to parent element:
* ```html
* <div
* data-controller="responsive-attr"
* data-responsive-attr-breakpoints-value='{"sm": 640, "md": 768, "lg": 1024, "xl": 1280, "2xl": 1536}'
* >
* <!-- 子元素 / Child elements -->
* </div>
* ```
*
* 2. 在需要响应式变化的子元素上添加属性 / Add responsive attributes to child elements:
* ```html
* <div
* data-id="123"
* data-controller="foo"
* md:data-controller="bar" // 在 md 断点时,使用 bar 控制器 / Use bar controller at md breakpoint
* lg:data-controller="baz" // 在 lg 断点时,使用 baz 控制器 / Use baz controller at lg breakpoint
* lg.remove:data-id // 在 lg 断点时,移除 data-id 属性 / Remove data-id attribute at lg breakpoint
* ></div>
* ```
*/
export default class extends Controller {
static values = {
breakpoints: Object,
};
initialize() {
this.breakpoints = []; // 存储断点配置 / Store breakpoint configurations
this.breakpointNames = []; // 存储断点名称 / Store breakpoint names
this.hookedElements = []; // 存储已挂钩的元素 / Store hooked elements
this.mediaQueryLists = new Map(); // 存储媒体查询列表 / Store media query lists
}
connect() {
// 防止重复初始化 / Prevent duplicate initialization
if (this.element.hasAttribute("responsive-attr-observed")) {
return;
}
// 不允许嵌套使用 / Nested usage is not allowed
if (this.element.closest("[responsive-attr-observed]")) {
console.warn("responsive-attr: 不允许嵌套使用 / Nested usage is not allowed");
return;
}
this.element[this.identifier] = this;
this.element.setAttribute("responsive-attr-observed", "true");
// 添加节点变化事件监听器 / Add mutation observer for DOM changes
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
// 处理新增和删除的节点 / Handle added and removed nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.#hookElement(node);
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.#unhookElement(node);
}
});
} else if (mutation.type === "attributes") {
// 检查属性变化 / Check attribute changes
const hookableAttribute = this.#getHookableAttribute(mutation.target, mutation.attributeName);
if (hookableAttribute) {
this.#hookElement(mutation.target);
}
}
});
});
this.observer.observe(this.element, {
childList: true,
subtree: true,
attributes: true,
});
this.#hookElement(this.element);
this.element.querySelectorAll("*").forEach((node) => {
this.#hookElement(node);
});
}
disconnect() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// 清理所有 MediaQueryList 监听器 / Clean up all MediaQueryList listeners
this.mediaQueryLists.forEach((mql) => {
mql.removeEventListener("change", this.#handleMediaQueryChange);
});
this.mediaQueryLists.clear();
this.hookedElements.forEach((element) => {
this.#unhookElement(element);
});
this.element[this.identifier] = null;
}
breakpointsValueChanged(value) {
// Tailwind CSS 默认断点 / Tailwind CSS default breakpoints:
// sm: '640px', // @media (min-width: 640px)
// md: '768px', // @media (min-width: 768px)
// lg: '1024px', // @media (min-width: 1024px)
// xl: '1280px', // @media (min-width: 1280px)
// 2xl: '1536px', // @media (min-width: 1536px)
const sortedBreakpoints = Object.entries(value).sort(([, a], [, b]) => a - b);
this.breakpoints = sortedBreakpoints.map(([name, width]) => {
return { name, minWidth: width };
});
this.breakpointNames = this.breakpoints.map(({ name }) => name);
// 设置 MediaQuery 监听器 / Setup MediaQuery listeners
this.#setupMediaQueries();
}
#setupMediaQueries() {
// 清理现有的 MediaQueryList 监听器 / Clean up existing MediaQueryList listeners
this.mediaQueryLists.forEach((mql) => {
mql.removeEventListener("change", this.#handleMediaQueryChange);
});
this.mediaQueryLists.clear();
// 为每个断点创建 MediaQueryList / Create MediaQueryList for each breakpoint
this.breakpoints.forEach(({ name, minWidth }) => {
const mediaQuery = window.matchMedia(`(min-width: ${minWidth}px)`);
mediaQuery.addEventListener("change", this.#handleMediaQueryChange.bind(this));
this.mediaQueryLists.set(name, mediaQuery);
});
// 初始检查所有元素 / Initial check for all elements
this.hookedElements.forEach((element) => {
this.#checkBreakpoint(element);
});
}
#handleMediaQueryChange = () => {
// 当媒体查询状态改变时,更新所有已hook的元素
// When media query state changes, update all hooked elements
this.hookedElements.forEach((element) => {
this.#checkBreakpoint(element);
});
}
#getHookableAttribute(element, name) {
let breakpoint = null,
attributeName = null,
remove = false;
[breakpoint, attributeName] = name.split(":", 2);
[breakpoint, remove] = breakpoint.split(".", 2);
remove = remove === "remove";
if (attributeName && this.breakpointNames.includes(breakpoint)) {
return {
name,
breakpoint,
attribute: attributeName,
value: remove ? null : element.getAttribute(name),
};
} else {
return null;
}
}
// 获取可响应的属性 / Get responsive attributes
// 支持的格式 / Supported formats:
// 1. 断点名:属性名 / breakpoint:attribute
// 2. 断点名.remove:属性名 / breakpoint.remove:attribute
// 例如 / Example:
// <div data-controller="foo" md:data-controller="bar"></div>
// <div data-controller="foo" md.remove:data-controller></div>
#getHookableAttributes(element) {
const hookableAttributes = [];
for (const attribute of element.attributes) {
const hookableAttribute = this.#getHookableAttribute(element, attribute.name);
if (hookableAttribute) {
hookableAttributes.push(hookableAttribute);
}
}
return hookableAttributes;
}
get #currentBreakpoint() {
const width = window.innerWidth;
// 从大到小遍历断点,返回第一个符合条件的
// Traverse breakpoints from large to small, return the first matching one
// 这样确保在多个断点条件满足时,使用最大的那个
// This ensures using the largest one when multiple breakpoints match
for (const breakpoint of [...this.breakpoints].reverse()) {
if (width >= breakpoint.minWidth) {
return breakpoint;
}
}
// 如果没有匹配的断点,说明是最小的尺寸(移动设备优先)
// If no breakpoint matches, it's the smallest size (mobile first)
return { name: "default", minWidth: 0 };
}
#checkBreakpoint(element) {
if (!element.responsiveAttrStore) return;
if (element.responsiveAttrStore.updating) return;
element.responsiveAttrStore.updating = true;
const currentBreakpoint = this.#currentBreakpoint;
const attributeChanges = new Map();
// 收集原始属性 / Collect original attributes
Object.entries(element.responsiveAttrStore.originalAttributes || {}).forEach(([attribute, value]) => {
attributeChanges.set(attribute, value);
});
// 按照断点从小到大的顺序应用属性 / Apply attributes in order from small to large breakpoints
this.breakpoints
.filter((bp) => {
const mql = this.mediaQueryLists.get(bp.name);
return mql && mql.matches;
})
.forEach((breakpoint) => {
const breakpointData = element.responsiveAttrStore.breakpoints[breakpoint.name];
if (breakpointData) {
Object.entries(breakpointData).forEach(([attribute, value]) => {
attributeChanges.set(attribute, value);
});
}
});
// 应用属性变更 / Apply attribute changes
attributeChanges.forEach((value, attribute) => {
if (value === null) {
element.hasAttribute(attribute) && element.removeAttribute(attribute);
} else {
element.getAttribute(attribute) !== value && element.setAttribute(attribute, value);
}
});
element.responsiveAttrStore.updating = false;
}
#hookElement(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
this.#unhookElement(element);
const hookableAttributes = this.#getHookableAttributes(element);
if (hookableAttributes.length === 0) return;
// 初始化存储对象 / Initialize storage object
element.responsiveAttrStore = {
breakpoints: {},
originalAttributes: {}
};
// 存储属性配置 / Store attribute configurations
hookableAttributes.forEach(({ breakpoint, attribute, value }) => {
element.responsiveAttrStore.breakpoints[breakpoint] ||= {};
element.responsiveAttrStore.breakpoints[breakpoint][attribute] = value;
element.responsiveAttrStore.originalAttributes[attribute] = element.getAttribute(attribute);
});
this.hookedElements.push(element);
element.setAttribute("responsive-attr-hooked", "true");
this.#checkBreakpoint(element);
}
#unhookElement(element) {
if (!element) return;
// 清理存储数据 / Clean up stored data
delete element.responsiveAttrStore;
// 从已挂钩元素列表中移除 / Remove from hooked elements list
const index = this.hookedElements.indexOf(element);
if (index > -1) {
this.hookedElements.splice(index, 1);
}
element.removeAttribute("responsive-attr-hooked");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment