Last active
February 28, 2025 02:51
-
-
Save xiaohui-zhangxh/9d0de40062e1de36423416d1da9f146c to your computer and use it in GitHub Desktop.
Responsive HTML Attributes like TailwindCSS (StimulusJS Controller)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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