responsive-classes.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { appSetting } from 'app/lib/util';
  2. /**
  3. * Build responsive class strings from theme utilities + breakpoint tiers.
  4. *
  5. * Prefixed class literals must stay listed in `@source inline(...)` in
  6. * `packages/app/design/styles/utilities.css` (Tailwind/Uniwind scanner).
  7. */
  8. type Tier = 'mobile' | 'tablet' | 'desktop';
  9. const TIER_PREFIX: Record<Tier, string> = { mobile: '', tablet: 'sm', desktop: 'lg' };
  10. const TIER_ORDER = ['mobile', 'tablet', 'desktop'] as const;
  11. /**
  12. * Responsive class preset: `on`/`off` utilities per tier, and a default for unset tiers.
  13. * `multi` presets keep every utility of `on`/`off` (e.g. fill + shadow + its dark variant);
  14. * the others use the first one.
  15. */
  16. type ResponsivePreset = { on: any; default?: any; off?: any; multi?: boolean };
  17. const PRESETS: Record<string, ResponsivePreset> = {
  18. padding: {
  19. on: () => appSetting('layout', 'page_content_padding'),
  20. default: () => appSetting('layout', 'page_content_padding_default'),
  21. off: 'p-0',
  22. },
  23. gap: {
  24. on: () => appSetting('layout', 'page_content_gap'),
  25. default: () => appSetting('layout', 'page_content_gap_default'),
  26. off: 'gap-0',
  27. },
  28. rounded: {
  29. on: () => appSetting('theme', 'blocks', 'u-block-rounded'),
  30. default: () => appSetting('theme', 'blocks', 'u-block-rounded-default'),
  31. off: 'rounded-none',
  32. },
  33. // Block chrome per tier (block config `designbox`, see page-block.js / block-wrapper.js).
  34. bg: {
  35. on: () => appSetting('theme', 'blocks', 'u-block-bg'),
  36. default: () => '',
  37. off: 'bg-transparent shadow-none dark:shadow-none',
  38. multi: true,
  39. },
  40. 'block-pad-y': {
  41. on: () => appSetting('theme', 'blocks', 'u-block-pad-y'),
  42. default: () => '',
  43. off: 'py-0',
  44. },
  45. 'block-pad-x': {
  46. on: () => appSetting('theme', 'blocks', 'u-block-pad-x'),
  47. default: () => '',
  48. off: 'px-0',
  49. },
  50. title: {
  51. on: () => 'flex',
  52. default: () => '',
  53. off: 'hidden',
  54. },
  55. // Space around a list card (default/functions.js `paddingForList`), where it shows.
  56. 'card-my': {
  57. on: () => 'my-4',
  58. default: () => '',
  59. off: 'my-0',
  60. },
  61. };
  62. /** @typedef {keyof typeof PRESETS} ResponsivePresetKey */
  63. /** @typedef {'mobile' | 'tablet' | 'desktop'} ResponsiveTier */
  64. /** @typedef {ResponsiveTier[] | ResponsiveTier | string | null | undefined | false | true} ResponsiveTiers */
  65. /** @type {readonly ResponsivePresetKey[]} */
  66. export const RESPONSIVE_PRESET_KEYS = Object.freeze(
  67. /** @type {ResponsivePresetKey[]} */ (Object.keys(PRESETS))
  68. );
  69. /** Strip breakpoint prefixes; keep the base utility token. */
  70. function baseUtil(token: string) {
  71. if (!token) return '';
  72. const part = String(token).trim().split(/\s+/).filter(Boolean)[0] ?? '';
  73. return part.replace(/^(?:sm:|md:|lg:|xl:|2xl:)+/, '');
  74. }
  75. function withPrefix(prefix: string, utility: string) {
  76. if (!utility) return '';
  77. return prefix ? `${prefix}:${utility}` : utility;
  78. }
  79. function parseTiers(value: any) {
  80. if (value == null || value === false || value === '') {
  81. return null;
  82. }
  83. if (value === true) {
  84. return [...TIER_ORDER];
  85. }
  86. if (Array.isArray(value)) {
  87. return value.map(String);
  88. }
  89. if (typeof value === 'string') {
  90. const trimmed = value.trim();
  91. if (!trimmed) return null;
  92. if (trimmed.startsWith('[')) {
  93. try {
  94. const parsed = JSON.parse(trimmed);
  95. if (Array.isArray(parsed)) {
  96. return parsed.map(String);
  97. }
  98. } catch {
  99. // fall through
  100. }
  101. }
  102. return [trimmed];
  103. }
  104. return null;
  105. }
  106. function isContiguous(selected: Tier[]) {
  107. if (selected.length <= 1) return true;
  108. const idx = selected.map((bp) => TIER_ORDER.indexOf(bp));
  109. return idx.every((v, i) => i === 0 || v === idx[i - 1]! + 1);
  110. }
  111. function buildResponsiveClasses(tiers: any, on: string, { off = 'rounded-none', default: defaultClass = '' }: { off?: string; default?: string } = {}) {
  112. const parsedTiers = parseTiers(tiers);
  113. if (parsedTiers === null) {
  114. return typeof defaultClass === 'string' ? defaultClass.trim() : '';
  115. }
  116. if (parsedTiers.length === 0) {
  117. return '';
  118. }
  119. const onUtil = baseUtil(on);
  120. const offUtil = baseUtil(off);
  121. if (!onUtil || !offUtil) {
  122. return typeof defaultClass === 'string' ? defaultClass.trim() : '';
  123. }
  124. const normalizedTiers = parsedTiers.map((tier) => tier.toLowerCase().trim());
  125. const selected = TIER_ORDER.filter((bp) => normalizedTiers.includes(bp));
  126. if (!selected.length) {
  127. return '';
  128. }
  129. const pick = (tier: any) =>
  130. withPrefix(TIER_PREFIX[tier as Tier], normalizedTiers.includes(tier) ? onUtil : offUtil);
  131. if (!isContiguous(selected)) {
  132. return TIER_ORDER.map(pick).filter(Boolean).join(' ');
  133. }
  134. const start = selected[0];
  135. const end = selected[selected.length - 1];
  136. // All tiers on → bare utility (no breakpoint prefixes).
  137. if (start === 'mobile' && end === 'desktop') {
  138. return onUtil;
  139. }
  140. const classes = [withPrefix('', start === 'mobile' ? onUtil : offUtil)];
  141. if (start === 'tablet' || (start === 'mobile' && end !== 'mobile')) {
  142. classes.push(withPrefix('sm', onUtil));
  143. } else {
  144. classes.push(withPrefix('sm', offUtil));
  145. }
  146. classes.push(withPrefix('lg', end === 'desktop' ? onUtil : offUtil));
  147. return classes.filter(Boolean).join(' ');
  148. }
  149. /** All utilities of `on` / `off` per tier; a tier only adds classes where it flips the tier below. */
  150. function buildResponsiveTokenSets(tiers: any, on: string, off: string, defaultClass = '') {
  151. const parsedTiers = parseTiers(tiers);
  152. if (parsedTiers === null) {
  153. return typeof defaultClass === 'string' ? defaultClass.trim() : '';
  154. }
  155. const listed = parsedTiers.map((tier) => tier.toLowerCase().trim());
  156. const tokens = (value: string) => String(value || '').trim().split(/\s+/).filter(Boolean);
  157. const classes: string[] = [];
  158. let previous: boolean | null = null;
  159. for (const tier of TIER_ORDER) {
  160. const isOn = listed.includes(tier);
  161. if (isOn !== previous) {
  162. classes.push(...tokens(isOn ? on : off).map((token) => withPrefix(TIER_PREFIX[tier], token)));
  163. previous = isOn;
  164. }
  165. }
  166. return classes.join(' ');
  167. }
  168. /**
  169. * @param {ResponsivePresetKey} presetKey
  170. * @param {ResponsiveTiers} [tiers]
  171. * @returns {string}
  172. */
  173. export function responsiveClasses(presetKey: string, tiers: any) {
  174. const preset = PRESETS[presetKey];
  175. if (!preset) return '';
  176. const defaultClass = preset.default ? preset.default() : '';
  177. if (preset.multi) {
  178. return buildResponsiveTokenSets(tiers, preset.on(), preset.off, defaultClass);
  179. }
  180. return buildResponsiveClasses(tiers, preset.on(), {
  181. off: preset.off,
  182. default: defaultClass,
  183. });
  184. }
  185. /**
  186. * A tiers value as `true` (every tier), `false` (none) or the tiers it lists, in
  187. * tier order. Booleans pass through, so a prop can take either (e.g. UNA's
  188. * designbox on/off, or per-breakpoint tiers from block config).
  189. */
  190. export function normalizeTiers(value: any): boolean | Tier[] {
  191. if (typeof value === 'boolean') return value;
  192. const parsedTiers = parseTiers(value);
  193. if (!parsedTiers) return false;
  194. const listed = parsedTiers.map((tier) => tier.toLowerCase().trim());
  195. const tiers = TIER_ORDER.filter((tier) => listed.includes(tier));
  196. if (tiers.length === TIER_ORDER.length) return true;
  197. return tiers.length ? tiers : false;
  198. }
  199. /**
  200. * Register a responsive preset. Add matching literals to `@source inline(...)` in utilities.css.
  201. */
  202. /** Add a preset usable as `responsiveClasses(key, tiers)` (forks can register their own). */
  203. export function registerResponsivePreset(key: string, { on, default: defaultFn, off, multi }: ResponsivePreset) {
  204. PRESETS[key] = { on, default: defaultFn, off, multi };
  205. }