responsive-classes.ts 8.6 KB

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