@flamingo-stack/openframe-frontend-core 0.0.591 → 0.0.592-snapshot.20260905013229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/{chunk-VGBTCUPG.js → chunk-2645GIM2.js} +2 -2
  2. package/dist/{chunk-BQE4HTDR.js → chunk-2JNCZDJR.js} +2 -2
  3. package/dist/{chunk-USDG6IWK.cjs → chunk-2KISKWOY.cjs} +7 -7
  4. package/dist/{chunk-USDG6IWK.cjs.map → chunk-2KISKWOY.cjs.map} +1 -1
  5. package/dist/{chunk-OBJEFMOI.cjs → chunk-2PMCHL37.cjs} +66 -66
  6. package/dist/{chunk-OBJEFMOI.cjs.map → chunk-2PMCHL37.cjs.map} +1 -1
  7. package/dist/{chunk-NXYOUBOL.cjs → chunk-2VOBGNEY.cjs} +21 -21
  8. package/dist/{chunk-NXYOUBOL.cjs.map → chunk-2VOBGNEY.cjs.map} +1 -1
  9. package/dist/{chunk-LUWFLC3T.js → chunk-3ZHDEZQB.js} +3 -3
  10. package/dist/{chunk-SW3CZ2KS.cjs → chunk-4DRZZWC7.cjs} +152 -86
  11. package/dist/chunk-4DRZZWC7.cjs.map +1 -0
  12. package/dist/{chunk-XK7SLEZB.cjs → chunk-5SGTTSQN.cjs} +3 -3
  13. package/dist/{chunk-XK7SLEZB.cjs.map → chunk-5SGTTSQN.cjs.map} +1 -1
  14. package/dist/{chunk-YMAHVHCX.cjs → chunk-BZEWPW5A.cjs} +3 -3
  15. package/dist/{chunk-YMAHVHCX.cjs.map → chunk-BZEWPW5A.cjs.map} +1 -1
  16. package/dist/{chunk-FHKP4H6N.cjs → chunk-CMAO4PPL.cjs} +5 -5
  17. package/dist/{chunk-FHKP4H6N.cjs.map → chunk-CMAO4PPL.cjs.map} +1 -1
  18. package/dist/{chunk-ZZF6Z7KB.cjs → chunk-FKXDDJEQ.cjs} +11 -11
  19. package/dist/{chunk-ZZF6Z7KB.cjs.map → chunk-FKXDDJEQ.cjs.map} +1 -1
  20. package/dist/{chunk-E455M3WJ.js → chunk-GEP3O6NU.js} +4 -4
  21. package/dist/{chunk-OKWGSJAI.js → chunk-GGQZBSFN.js} +1 -1
  22. package/dist/chunk-GGQZBSFN.js.map +1 -0
  23. package/dist/{chunk-RGCBUNMJ.js → chunk-GXPOEAXL.js} +2 -2
  24. package/dist/{chunk-RNXY35DZ.cjs → chunk-H64ZHSYR.cjs} +1 -1
  25. package/dist/chunk-H64ZHSYR.cjs.map +1 -0
  26. package/dist/{chunk-AMCARR6Y.cjs → chunk-HMRWH2HC.cjs} +15 -15
  27. package/dist/{chunk-AMCARR6Y.cjs.map → chunk-HMRWH2HC.cjs.map} +1 -1
  28. package/dist/{chunk-ZFHIMK4W.js → chunk-J3D2WHWC.js} +5 -5
  29. package/dist/{chunk-T6RY2MC6.js → chunk-KPO6TWEV.js} +8 -8
  30. package/dist/{chunk-CWO3YFVZ.js → chunk-LRQCKSZC.js} +2 -2
  31. package/dist/{chunk-2TJSDSXW.js → chunk-MOHSA65M.js} +2 -2
  32. package/dist/{chunk-4UP2SGSO.js → chunk-MPNCYET3.js} +3 -3
  33. package/dist/{chunk-U2XRBHBX.js → chunk-MV3XJXPG.js} +9 -9
  34. package/dist/{chunk-JHVCH3CK.cjs → chunk-NEFNARFF.cjs} +6 -6
  35. package/dist/{chunk-JHVCH3CK.cjs.map → chunk-NEFNARFF.cjs.map} +1 -1
  36. package/dist/{chunk-HV6MO4HG.cjs → chunk-NRHU4V3E.cjs} +37 -37
  37. package/dist/{chunk-HV6MO4HG.cjs.map → chunk-NRHU4V3E.cjs.map} +1 -1
  38. package/dist/{chunk-B7ZCBGUN.cjs → chunk-O4VZOA2Q.cjs} +3 -3
  39. package/dist/{chunk-B7ZCBGUN.cjs.map → chunk-O4VZOA2Q.cjs.map} +1 -1
  40. package/dist/{chunk-YEKPHSWC.cjs → chunk-OAAQKMMU.cjs} +22 -22
  41. package/dist/{chunk-YEKPHSWC.cjs.map → chunk-OAAQKMMU.cjs.map} +1 -1
  42. package/dist/{chunk-DKVNW2QD.cjs → chunk-QHTDFF52.cjs} +3 -3
  43. package/dist/{chunk-DKVNW2QD.cjs.map → chunk-QHTDFF52.cjs.map} +1 -1
  44. package/dist/{chunk-LL6J6N76.js → chunk-R2J6XMP6.js} +3 -3
  45. package/dist/{chunk-WKOJL6A2.cjs → chunk-S6G6KJ57.cjs} +10 -10
  46. package/dist/{chunk-WKOJL6A2.cjs.map → chunk-S6G6KJ57.cjs.map} +1 -1
  47. package/dist/{chunk-VGY7D6LG.cjs → chunk-STYNZ4QM.cjs} +3 -3
  48. package/dist/{chunk-VGY7D6LG.cjs.map → chunk-STYNZ4QM.cjs.map} +1 -1
  49. package/dist/{chunk-BOED5ISE.js → chunk-UG27ITVP.js} +6 -6
  50. package/dist/{chunk-AZ4A4RBL.cjs → chunk-VDAMJEMR.cjs} +60 -60
  51. package/dist/{chunk-AZ4A4RBL.cjs.map → chunk-VDAMJEMR.cjs.map} +1 -1
  52. package/dist/{chunk-ZYA6274A.js → chunk-VUGQ3Z6E.js} +110 -44
  53. package/dist/chunk-VUGQ3Z6E.js.map +1 -0
  54. package/dist/{chunk-5SJSG5RV.cjs → chunk-WY2GQ62H.cjs} +8 -8
  55. package/dist/{chunk-5SJSG5RV.cjs.map → chunk-WY2GQ62H.cjs.map} +1 -1
  56. package/dist/{chunk-CXILZQFT.cjs → chunk-WZHARPOT.cjs} +19 -19
  57. package/dist/{chunk-CXILZQFT.cjs.map → chunk-WZHARPOT.cjs.map} +1 -1
  58. package/dist/{chunk-WMZ5AUTB.js → chunk-WZJOILPU.js} +2 -2
  59. package/dist/{chunk-QVAKJB64.js → chunk-X7XO73M5.js} +2 -2
  60. package/dist/{chunk-ALTSOKHP.js → chunk-XH5QBMXD.js} +2 -2
  61. package/dist/{chunk-WIMGAPVH.js → chunk-YAZY24ZK.js} +4 -4
  62. package/dist/{chunk-OS72RV57.cjs → chunk-YSJ4TA6V.cjs} +3 -3
  63. package/dist/{chunk-OS72RV57.cjs.map → chunk-YSJ4TA6V.cjs.map} +1 -1
  64. package/dist/{chunk-U3V4TPT5.js → chunk-ZR72HRUJ.js} +3 -3
  65. package/dist/{chunk-BATTVHZT.js → chunk-ZZHHKE2Q.js} +3 -3
  66. package/dist/components/case-studies/index.cjs +10 -10
  67. package/dist/components/case-studies/index.js +4 -4
  68. package/dist/components/contact/index.cjs +5 -5
  69. package/dist/components/contact/index.js +4 -4
  70. package/dist/components/docs/index.cjs +8 -8
  71. package/dist/components/docs/index.js +7 -7
  72. package/dist/components/embeds/index.cjs +5 -5
  73. package/dist/components/embeds/index.js +4 -4
  74. package/dist/components/faq/index.cjs +6 -6
  75. package/dist/components/faq/index.js +5 -5
  76. package/dist/components/features/index.cjs +4 -4
  77. package/dist/components/features/index.js +3 -3
  78. package/dist/components/help-center-pages/index.cjs +27 -27
  79. package/dist/components/help-center-pages/index.js +18 -18
  80. package/dist/components/index.cjs +40 -40
  81. package/dist/components/index.js +18 -18
  82. package/dist/components/meeting-scheduler/index.cjs +31 -31
  83. package/dist/components/meeting-scheduler/index.js +5 -5
  84. package/dist/components/navigation/index.cjs +7 -7
  85. package/dist/components/navigation/index.js +6 -6
  86. package/dist/components/onboarding-guides/index.cjs +8 -8
  87. package/dist/components/onboarding-guides/index.js +7 -7
  88. package/dist/components/related-content/index.cjs +4 -4
  89. package/dist/components/related-content/index.js +3 -3
  90. package/dist/components/tickets/index.cjs +9 -9
  91. package/dist/components/tickets/index.js +8 -8
  92. package/dist/components/ui/data-table/data-table-body.d.ts +18 -1
  93. package/dist/components/ui/data-table/data-table-body.d.ts.map +1 -1
  94. package/dist/components/ui/data-table/data-table-row.d.ts +13 -1
  95. package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
  96. package/dist/components/ui/data-table/data-table-skeleton.d.ts +9 -1
  97. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  98. package/dist/components/ui/data-table/index.d.ts +1 -1
  99. package/dist/components/ui/entity-image.d.ts +12 -1
  100. package/dist/components/ui/entity-image.d.ts.map +1 -1
  101. package/dist/components/ui/index.cjs +4 -4
  102. package/dist/components/ui/index.js +3 -3
  103. package/dist/hooks/index.cjs +3 -3
  104. package/dist/hooks/index.js +2 -2
  105. package/dist/index.cjs +8 -8
  106. package/dist/index.js +7 -7
  107. package/dist/utils/index.cjs.map +1 -1
  108. package/dist/utils/index.js.map +1 -1
  109. package/dist/utils/search-params.cjs.map +1 -1
  110. package/dist/utils/search-params.js.map +1 -1
  111. package/package.json +1 -1
  112. package/src/__tests__/data-table-row-height.test.tsx +170 -0
  113. package/src/__tests__/mlg-platform-leaves.test.ts +1 -1
  114. package/src/components/ui/data-table/data-table-body.tsx +46 -7
  115. package/src/components/ui/data-table/data-table-row.tsx +38 -8
  116. package/src/components/ui/data-table/data-table-skeleton.tsx +27 -3
  117. package/src/components/ui/entity-image.tsx +19 -3
  118. package/src/hooks/state/__tests__/use-api-params.test.ts +1 -1
  119. package/src/utils/search-params.ts +4 -4
  120. package/dist/chunk-OKWGSJAI.js.map +0 -1
  121. package/dist/chunk-RNXY35DZ.cjs.map +0 -1
  122. package/dist/chunk-SW3CZ2KS.cjs.map +0 -1
  123. package/dist/chunk-ZYA6274A.js.map +0 -1
  124. /package/dist/{chunk-VGBTCUPG.js.map → chunk-2645GIM2.js.map} +0 -0
  125. /package/dist/{chunk-BQE4HTDR.js.map → chunk-2JNCZDJR.js.map} +0 -0
  126. /package/dist/{chunk-LUWFLC3T.js.map → chunk-3ZHDEZQB.js.map} +0 -0
  127. /package/dist/{chunk-E455M3WJ.js.map → chunk-GEP3O6NU.js.map} +0 -0
  128. /package/dist/{chunk-RGCBUNMJ.js.map → chunk-GXPOEAXL.js.map} +0 -0
  129. /package/dist/{chunk-ZFHIMK4W.js.map → chunk-J3D2WHWC.js.map} +0 -0
  130. /package/dist/{chunk-T6RY2MC6.js.map → chunk-KPO6TWEV.js.map} +0 -0
  131. /package/dist/{chunk-CWO3YFVZ.js.map → chunk-LRQCKSZC.js.map} +0 -0
  132. /package/dist/{chunk-2TJSDSXW.js.map → chunk-MOHSA65M.js.map} +0 -0
  133. /package/dist/{chunk-4UP2SGSO.js.map → chunk-MPNCYET3.js.map} +0 -0
  134. /package/dist/{chunk-U2XRBHBX.js.map → chunk-MV3XJXPG.js.map} +0 -0
  135. /package/dist/{chunk-LL6J6N76.js.map → chunk-R2J6XMP6.js.map} +0 -0
  136. /package/dist/{chunk-BOED5ISE.js.map → chunk-UG27ITVP.js.map} +0 -0
  137. /package/dist/{chunk-WMZ5AUTB.js.map → chunk-WZJOILPU.js.map} +0 -0
  138. /package/dist/{chunk-QVAKJB64.js.map → chunk-X7XO73M5.js.map} +0 -0
  139. /package/dist/{chunk-ALTSOKHP.js.map → chunk-XH5QBMXD.js.map} +0 -0
  140. /package/dist/{chunk-WIMGAPVH.js.map → chunk-YAZY24ZK.js.map} +0 -0
  141. /package/dist/{chunk-U3V4TPT5.js.map → chunk-ZR72HRUJ.js.map} +0 -0
  142. /package/dist/{chunk-BATTVHZT.js.map → chunk-ZZHHKE2Q.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/search-params.ts"],"names":[],"mappings":"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","file":"search-params.cjs","sourcesContent":["/**\n * URL parameter schema engine — the ONE grammar for every URL/API integer,\n * the ONE omission rule, the ONE serializer.\n *\n * Hoisted out of `hooks/state/` so SERVER code (route handlers, DALs) and the\n * CLIENT hook parse the same URL with the same function: a param contract that\n * disagrees across the wire is the bug class this removes. JSX-free leaf with\n * its own `exports` subpath; `hooks/state/*` re-exports every name it used to own.\n */\n\n/** JavaScript types a URL parameter can carry. `int` is a whole number with a floor. */\nexport type JSType = 'string' | 'number' | 'int' | 'boolean' | 'array' | 'object';\n\n// ── The integer grammar ─────────────────────────────────────────────────────\n\nexport interface PositiveIntOptions {\n /** Smallest accepted value (default 1). Anything below falls back. */\n min?: number;\n /** Largest accepted value. Applied to the PARSED value AND to the fallback. */\n max?: number;\n}\n\n/**\n * THE integer grammar for URL params, env vars, and page sizes.\n *\n * `null`/`undefined`/`''` → fallback. Otherwise `Math.trunc(Number(raw))`, which\n * accepts `'12'`, `12`, `'12.9'` (→ 12) and `'0x10'` (→ 16), and rejects `'12abc'`\n * and `'abc'` (`NaN`) — the historical `parseInt` accepted `'12abc'` as 12 and\n * produced `NaN` for `'abc'`. A value below `min` falls back. The RESULT (parsed\n * value or fallback alike) is clamped to `max`, so \"never exceeds max\" has one owner.\n */\nexport function positiveInt<TFallback = number>(\n raw: string | number | null | undefined,\n fallback: TFallback,\n { min = 1, max }: PositiveIntOptions = {},\n): number | TFallback {\n // The fallback is returned AS GIVEN — capping only ever applies to a value\n // parsed out of the input, so a caller with no default (`null`) gets its\n // sentinel back untouched instead of `Math.min(null, max)`.\n const capped = (v: number) => (max == null ? v : Math.min(v, max));\n if (raw === null || raw === undefined || raw === '') {\n return typeof fallback === 'number' ? (capped(fallback) as number | TFallback) : fallback;\n }\n const n = Math.trunc(Number(raw));\n if (!Number.isFinite(n) || n < min) {\n return typeof fallback === 'number' ? (capped(fallback) as number | TFallback) : fallback;\n }\n return capped(n);\n}\n\n/** Ceilings every paged surface shares. */\nexport const PAGE_PARAM_LIMITS = { maxPage: 10_000, maxPageSize: 100 } as const;\n\n/** Total pages for a row count. Floors at 1, so an empty result is still \"page 1 of 1\". */\nexport function pageCount(total: number, pageSize: number): number {\n if (!Number.isFinite(total) || !Number.isFinite(pageSize) || pageSize <= 0) return 1;\n return Math.max(1, Math.ceil(total / pageSize));\n}\n\n/** Clamp a requested page into `1..totalPages`. */\nexport function clampPageToTotal(page: number, totalPages: number): number {\n return Math.min(page, Math.max(1, totalPages));\n}\n\n// ── Schema ──────────────────────────────────────────────────────────────────\n\ntype OutputTypeMap = {\n string: string;\n number: number;\n int: number;\n boolean: boolean;\n array: string[];\n object: Record<string, unknown>;\n};\n\nexport type OutputTypeForJSType<T extends JSType> = OutputTypeMap[T];\n\nexport interface BaseParamConfig<T extends JSType = JSType> {\n type: T;\n default?: OutputTypeMap[T];\n required?: boolean;\n}\n\n/** An integer parameter with an optional floor and ceiling. */\nexport interface IntParamConfig extends BaseParamConfig<'int'> {\n min?: number;\n max?: number;\n}\n\nexport type ParamConfig<T extends JSType = JSType> = T extends 'int' ? IntParamConfig : BaseParamConfig<T>;\n\nexport type ParamSchema = Record<string, ParamConfig>;\n\n/** Identity helper that preserves literal types through a schema object. */\nexport function defineParamSchema<T extends ParamSchema>(schema: T): T {\n return schema;\n}\n\n/**\n * Build a `Record<K, V>` from a key tuple. Keeps `keyof` EXACT when spreading a\n * shared config across derived keys (an inline `Object.fromEntries` widens to\n * `Record<string, V>` and loses the key union).\n */\nexport function fromKeys<K extends string, V>(keys: readonly K[], value: V): Record<K, V> {\n return Object.fromEntries(keys.map(k => [k, value])) as Record<K, V>;\n}\n\n// ── Reading ─────────────────────────────────────────────────────────────────\n\n/** Anything a URL can be read from: `URLSearchParams`, Next's `searchParams`, or a plain record. */\nexport type ParamInput = URLSearchParams | Record<string, string | string[] | undefined> | null | undefined;\n\n/** THE first-value rule for a duplicated key (`?a=1&a=2` reads as `1`). */\nexport function firstParamValue(input: ParamInput, key: string): string | undefined {\n if (!input) return undefined;\n if (input instanceof URLSearchParams) return input.get(key) ?? undefined;\n const value = input[key];\n return Array.isArray(value) ? value[0] : value;\n}\n\n/** Every value for a key (arrays read repeated params, then a `,`-joined single value). */\nfunction allParamValues(input: ParamInput, key: string): string[] {\n if (!input) return [];\n const raw = input instanceof URLSearchParams ? input.getAll(key) : input[key];\n const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];\n if (list.length === 1 && list[0]?.includes(',')) return list[0].split(',').filter(Boolean);\n return list.filter(v => v !== undefined && v !== '');\n}\n\nexport interface ParseSchemaOptions {\n /**\n * What an ABSENT scalar with no declared `default` resolves to.\n * `'undefined'` (default) keeps today's behaviour; `'null'` is the explicit\n * \"this filter is unset\" spelling that survives JSON and `URLSearchParams`.\n */\n absent?: 'undefined' | 'null';\n}\n\nexport type AbsentValue<Opts extends ParseSchemaOptions | undefined> = Opts extends { absent: 'null' }\n ? null\n : undefined;\n\n/**\n * The parsed shape of a schema: keys with a `default` and every array key are\n * non-nullable; an undefaulted scalar carries the caller's absent value.\n */\nexport type InferParsedParams<TSchema extends ParamSchema, Absent = undefined> = {\n [K in keyof TSchema]: TSchema[K] extends { type: 'array' }\n ? string[]\n : TSchema[K] extends { default: infer D }\n ? D\n : TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T> | Absent\n : never\n : never;\n};\n\nfunction coerceScalar(raw: string | undefined, config: ParamConfig): unknown {\n if (raw === undefined || raw === '') return undefined;\n switch (config.type) {\n case 'int': {\n const int = config as IntParamConfig;\n // ONE integer grammar, default or not. Without a declared default the\n // fallback is `undefined` (an unparseable value is absent), which\n // `positiveInt` returns untouched — the branch used to open-code the\n // truncation, the min floor and the max clamp a second time.\n return positiveInt<undefined>(raw, int.default as undefined, { min: int.min, max: int.max });\n }\n case 'number': {\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n }\n case 'boolean':\n return raw === 'true' || raw === '1';\n case 'object':\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return undefined;\n }\n default:\n return raw;\n }\n}\n\n/**\n * Parse a URL into a schema's shape. THE reader — the server route and the\n * client hook both call this, so a URL means exactly one thing.\n *\n * PRECEDENCE: an absent or empty SCALAR takes the declared `default` when there\n * is one, else the `absent` value. ARRAY keys never take `absent`: they are `[]`.\n */\nexport function parseSchemaParams<TSchema extends ParamSchema, Opts extends ParseSchemaOptions = Record<string, never>>(\n schema: TSchema,\n input: ParamInput,\n options?: Opts,\n): InferParsedParams<TSchema, AbsentValue<Opts>> {\n const absent = options?.absent === 'null' ? null : undefined;\n const out: Record<string, unknown> = {};\n for (const [key, config] of Object.entries(schema)) {\n if (config.type === 'array') {\n const values = allParamValues(input, key);\n out[key] = values.length > 0 ? values : ((config.default as string[] | undefined) ?? []);\n continue;\n }\n const coerced = coerceScalar(firstParamValue(input, key), config);\n out[key] = coerced !== undefined ? coerced : (config.default ?? absent);\n }\n return out as InferParsedParams<TSchema, AbsentValue<Opts>>;\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────\n\n/**\n * A schema entry OR a legacy flattened param (which spells its default\n * `defaultValue`).\n *\n * A UNION, not a bag of two optional fields: with only `default?` and\n * `defaultValue?` this is a WEAK TYPE, and TypeScript rejects any argument\n * sharing neither — including `{ type: 'string' }`, the single most common\n * schema entry there is. Naming the real config shapes fixes that without an\n * index signature, which `FlattenedParam` (an interface) could never satisfy.\n */\ntype OmissionConfig = ParamConfig | { default?: unknown; defaultValue?: unknown };\n\n/**\n * THE omission rule: `null`, `undefined`, `''`, `[]`, and a value equal to the\n * declared default are left out, so a URL only ever carries what differs.\n */\nexport function shouldIncludeInUrl(value: unknown, config: OmissionConfig | undefined): boolean {\n if (value === null || value === undefined) return false;\n if (Array.isArray(value) && value.length === 0) return false;\n if (value === '') return false;\n // `in` narrowing rather than a cast: the union's members genuinely differ —\n // a schema entry spells its default `default`, a flattened param spells it\n // `defaultValue`, and neither is obliged to carry the other's field.\n const fromDefault = config && 'default' in config ? config.default : undefined;\n const fromLegacy = config && 'defaultValue' in config ? config.defaultValue : undefined;\n const declared = fromDefault !== undefined ? fromDefault : fromLegacy;\n if (declared !== undefined && value === declared) return false;\n return true;\n}\n\nexport interface CreateSearchParamsOptions {\n /** Join arrays with this separator instead of repeating the key (`;` for the hub's list APIs). */\n arrayJoin?: string;\n}\n\n/** Build `URLSearchParams` from a value record (arrays repeat the key by default). */\nexport function createSearchParams(\n params: Record<string, unknown>,\n { arrayJoin }: CreateSearchParamsOptions = {},\n): URLSearchParams {\n const search = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n // The emptiness half of the omission rule has ONE owner. (No config here:\n // callers that must also drop schema DEFAULTS filter through\n // `shouldIncludeInUrl` with the config first — see `serializeSchemaParams`.)\n if (!shouldIncludeInUrl(value, undefined)) continue;\n if (Array.isArray(value)) {\n // Empty ELEMENTS are dropped, and an array that filters to nothing is\n // absent (a cleared multi-select must not leave `?tags=` behind).\n const items = value.filter(v => v !== null && v !== undefined && v !== '');\n if (items.length === 0) continue;\n if (arrayJoin) search.set(key, items.join(arrayJoin));\n else for (const item of items) search.append(key, String(item));\n continue;\n }\n if (typeof value === 'object') {\n search.set(key, JSON.stringify(value));\n continue;\n }\n search.set(key, String(value));\n }\n return search;\n}\n\n/** Serialize params to a query string, omitting everything `shouldIncludeInUrl` rejects. */\nexport function serializeSchemaParams<TSchema extends ParamSchema>(\n schema: TSchema,\n params: Record<string, unknown>,\n options?: CreateSearchParamsOptions,\n): string {\n const kept: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(params)) {\n if (shouldIncludeInUrl(value, schema[key])) kept[key] = value;\n }\n return createSearchParams(kept, options).toString();\n}\n\n/** Append a query string to a path, or return the path unchanged when it is empty. */\nexport function withQuery(path: string, queryString: string | null | undefined): string {\n return queryString ? `${path}?${queryString}` : path;\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/search-params.ts"],"names":[],"mappings":";;;AA+BO,SAAS,WAAA,CACd,KACA,QAAA,EACA,EAAE,MAAM,CAAA,EAAG,GAAA,EAAI,GAAwB,EAAC,EACpB;AAIpB,EAAA,MAAM,MAAA,GAAS,CAAC,CAAA,KAAe,GAAA,IAAO,OAAO,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,GAAG,CAAA;AAChE,EAAA,IAAI,GAAA,KAAQ,IAAA,IAAQ,GAAA,KAAQ,MAAA,IAAa,QAAQ,EAAA,EAAI;AACnD,IAAA,OAAO,OAAO,QAAA,KAAa,QAAA,GAAW,MAAA,CAAO,QAAQ,CAAA,GAAI,QAAA;AAAA,EAC3D;AACA,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,GAAG,CAAC,CAAA;AAChC,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,IAAK,IAAI,GAAA,EAAK;AAClC,IAAA,OAAO,OAAO,QAAA,KAAa,QAAA,GAAW,MAAA,CAAO,QAAQ,CAAA,GAAI,QAAA;AAAA,EAC3D;AACA,EAAA,OAAO,OAAO,CAAC,CAAA;AACjB;AAGO,IAAM,iBAAA,GAAoB,EAAE,OAAA,EAAS,GAAA,EAAQ,aAAa,GAAA;AAG1D,SAAS,SAAA,CAAU,OAAe,QAAA,EAA0B;AACjE,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA,IAAK,CAAC,MAAA,CAAO,QAAA,CAAS,QAAQ,CAAA,IAAK,QAAA,IAAY,CAAA,EAAG,OAAO,CAAA;AACnF,EAAA,OAAO,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,IAAA,CAAK,KAAA,GAAQ,QAAQ,CAAC,CAAA;AAChD;AAGO,SAAS,gBAAA,CAAiB,MAAc,UAAA,EAA4B;AACzE,EAAA,OAAO,KAAK,GAAA,CAAI,IAAA,EAAM,KAAK,GAAA,CAAI,CAAA,EAAG,UAAU,CAAC,CAAA;AAC/C;AAgCO,SAAS,kBAAyC,MAAA,EAAc;AACrE,EAAA,OAAO,MAAA;AACT;AAOO,SAAS,QAAA,CAA8B,MAAoB,KAAA,EAAwB;AACxF,EAAA,OAAO,MAAA,CAAO,YAAY,IAAA,CAAK,GAAA,CAAI,OAAK,CAAC,CAAA,EAAG,KAAK,CAAC,CAAC,CAAA;AACrD;AAQO,SAAS,eAAA,CAAgB,OAAmB,GAAA,EAAiC;AAClF,EAAA,IAAI,CAAC,OAAO,OAAO,MAAA;AACnB,EAAA,IAAI,iBAAiB,eAAA,EAAiB,OAAO,KAAA,CAAM,GAAA,CAAI,GAAG,CAAA,IAAK,MAAA;AAC/D,EAAA,MAAM,KAAA,GAAQ,MAAM,GAAG,CAAA;AACvB,EAAA,OAAO,MAAM,OAAA,CAAQ,KAAK,CAAA,GAAI,KAAA,CAAM,CAAC,CAAA,GAAI,KAAA;AAC3C;AAGA,SAAS,cAAA,CAAe,OAAmB,GAAA,EAAuB;AAChE,EAAA,IAAI,CAAC,KAAA,EAAO,OAAO,EAAC;AACpB,EAAA,MAAM,GAAA,GAAM,iBAAiB,eAAA,GAAkB,KAAA,CAAM,OAAO,GAAG,CAAA,GAAI,MAAM,GAAG,CAAA;AAC5E,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,GAAI,GAAA,GAAM,GAAA,KAAQ,MAAA,GAAY,EAAC,GAAI,CAAC,GAAG,CAAA;AACrE,EAAA,IAAI,KAAK,MAAA,KAAW,CAAA,IAAK,IAAA,CAAK,CAAC,GAAG,QAAA,CAAS,GAAG,CAAA,EAAG,OAAO,KAAK,CAAC,CAAA,CAAE,MAAM,GAAG,CAAA,CAAE,OAAO,OAAO,CAAA;AACzF,EAAA,OAAO,KAAK,MAAA,CAAO,CAAA,CAAA,KAAK,CAAA,KAAM,MAAA,IAAa,MAAM,EAAE,CAAA;AACrD;AA+BA,SAAS,YAAA,CAAa,KAAyB,MAAA,EAA8B;AAC3E,EAAA,IAAI,GAAA,KAAQ,MAAA,IAAa,GAAA,KAAQ,EAAA,EAAI,OAAO,MAAA;AAC5C,EAAA,QAAQ,OAAO,IAAA;AAAM,IACnB,KAAK,KAAA,EAAO;AACV,MAAA,MAAM,GAAA,GAAM,MAAA;AAKZ,MAAA,OAAO,WAAA,CAAuB,GAAA,EAAK,GAAA,CAAI,OAAA,EAAsB,EAAE,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,CAAA;AAAA,IAC7F;AAAA,IACA,KAAK,QAAA,EAAU;AACb,MAAA,MAAM,CAAA,GAAI,OAAO,GAAG,CAAA;AACpB,MAAA,OAAO,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,GAAI,CAAA,GAAI,MAAA;AAAA,IAClC;AAAA,IACA,KAAK,SAAA;AACH,MAAA,OAAO,GAAA,KAAQ,UAAU,GAAA,KAAQ,GAAA;AAAA,IACnC,KAAK,QAAA;AACH,MAAA,IAAI;AACF,QAAA,OAAO,IAAA,CAAK,MAAM,GAAG,CAAA;AAAA,MACvB,CAAA,CAAA,MAAQ;AACN,QAAA,OAAO,MAAA;AAAA,MACT;AAAA,IACF;AACE,MAAA,OAAO,GAAA;AAAA;AAEb;AASO,SAAS,iBAAA,CACd,MAAA,EACA,KAAA,EACA,OAAA,EAC+C;AAC/C,EAAA,MAAM,MAAA,GAAS,OAAA,EAAS,MAAA,KAAW,MAAA,GAAS,IAAA,GAAO,MAAA;AACnD,EAAA,MAAM,MAA+B,EAAC;AACtC,EAAA,KAAA,MAAW,CAAC,GAAA,EAAK,MAAM,KAAK,MAAA,CAAO,OAAA,CAAQ,MAAM,CAAA,EAAG;AAClD,IAAA,IAAI,MAAA,CAAO,SAAS,OAAA,EAAS;AAC3B,MAAA,MAAM,MAAA,GAAS,cAAA,CAAe,KAAA,EAAO,GAAG,CAAA;AACxC,MAAA,GAAA,CAAI,GAAG,IAAI,MAAA,CAAO,MAAA,GAAS,IAAI,MAAA,GAAU,MAAA,CAAO,WAAW,EAAC;AAC5D,MAAA;AAAA,IACF;AACA,IAAA,MAAM,UAAU,YAAA,CAAa,eAAA,CAAgB,KAAA,EAAO,GAAG,GAAG,MAAM,CAAA;AAChE,IAAA,GAAA,CAAI,GAAG,CAAA,GAAI,OAAA,KAAY,MAAA,GAAY,OAAA,GAAW,OAAO,OAAA,IAAW,MAAA;AAAA,EAClE;AACA,EAAA,OAAO,GAAA;AACT;AAoBO,SAAS,kBAAA,CAAmB,OAAgB,MAAA,EAA6C;AAC9F,EAAA,IAAI,KAAA,KAAU,IAAA,IAAQ,KAAA,KAAU,MAAA,EAAW,OAAO,KAAA;AAClD,EAAA,IAAI,MAAM,OAAA,CAAQ,KAAK,KAAK,KAAA,CAAM,MAAA,KAAW,GAAG,OAAO,KAAA;AACvD,EAAA,IAAI,KAAA,KAAU,IAAI,OAAO,KAAA;AAIzB,EAAA,MAAM,WAAA,GAAc,MAAA,IAAU,SAAA,IAAa,MAAA,GAAS,OAAO,OAAA,GAAU,MAAA;AACrE,EAAA,MAAM,UAAA,GAAa,MAAA,IAAU,cAAA,IAAkB,MAAA,GAAS,OAAO,YAAA,GAAe,MAAA;AAC9E,EAAA,MAAM,QAAA,GAAW,WAAA,KAAgB,MAAA,GAAY,WAAA,GAAc,UAAA;AAC3D,EAAA,IAAI,QAAA,KAAa,MAAA,IAAa,KAAA,KAAU,QAAA,EAAU,OAAO,KAAA;AACzD,EAAA,OAAO,IAAA;AACT;AAQO,SAAS,mBACd,MAAA,EACA,EAAE,SAAA,EAAU,GAA+B,EAAC,EAC3B;AACjB,EAAA,MAAM,MAAA,GAAS,IAAI,eAAA,EAAgB;AACnC,EAAA,KAAA,MAAW,CAAC,GAAA,EAAK,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,MAAM,CAAA,EAAG;AAIjD,IAAA,IAAI,CAAC,kBAAA,CAAmB,KAAA,EAAO,MAAS,CAAA,EAAG;AAC3C,IAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,EAAG;AAGxB,MAAA,MAAM,KAAA,GAAQ,MAAM,MAAA,CAAO,CAAA,CAAA,KAAK,MAAM,IAAA,IAAQ,CAAA,KAAM,MAAA,IAAa,CAAA,KAAM,EAAE,CAAA;AACzE,MAAA,IAAI,KAAA,CAAM,WAAW,CAAA,EAAG;AACxB,MAAA,IAAI,WAAW,MAAA,CAAO,GAAA,CAAI,KAAK,KAAA,CAAM,IAAA,CAAK,SAAS,CAAC,CAAA;AAAA,WAC/C,KAAA,MAAW,QAAQ,KAAA,EAAO,MAAA,CAAO,OAAO,GAAA,EAAK,MAAA,CAAO,IAAI,CAAC,CAAA;AAC9D,MAAA;AAAA,IACF;AACA,IAAA,IAAI,OAAO,UAAU,QAAA,EAAU;AAC7B,MAAA,MAAA,CAAO,GAAA,CAAI,GAAA,EAAK,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA;AACrC,MAAA;AAAA,IACF;AACA,IAAA,MAAA,CAAO,GAAA,CAAI,GAAA,EAAK,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,EAC/B;AACA,EAAA,OAAO,MAAA;AACT;AAGO,SAAS,qBAAA,CACd,MAAA,EACA,MAAA,EACA,OAAA,EACQ;AACR,EAAA,MAAM,OAAgC,EAAC;AACvC,EAAA,KAAA,MAAW,CAAC,GAAA,EAAK,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,MAAM,CAAA,EAAG;AACjD,IAAA,IAAI,kBAAA,CAAmB,OAAO,MAAA,CAAO,GAAG,CAAC,CAAA,EAAG,IAAA,CAAK,GAAG,CAAA,GAAI,KAAA;AAAA,EAC1D;AACA,EAAA,OAAO,kBAAA,CAAmB,IAAA,EAAM,OAAO,CAAA,CAAE,QAAA,EAAS;AACpD;AAGO,SAAS,SAAA,CAAU,MAAc,WAAA,EAAgD;AACtF,EAAA,OAAO,WAAA,GAAc,CAAA,EAAG,IAAI,CAAA,CAAA,EAAI,WAAW,CAAA,CAAA,GAAK,IAAA;AAClD","file":"search-params.cjs","sourcesContent":["/**\n * URL parameter schema engine — the ONE grammar for every URL/API integer,\n * the ONE omission rule, the ONE serializer.\n *\n * Hoisted out of `hooks/state/` so SERVER code (route handlers, DALs) and the\n * CLIENT hook parse the same URL with the same function: a param contract that\n * disagrees across the wire is the bug class this removes. JSX-free leaf with\n * its own `exports` subpath; `hooks/state/*` re-exports every name it used to own.\n */\n\n/** JavaScript types a URL parameter can carry. `int` is a whole number with a floor. */\nexport type JSType = 'string' | 'number' | 'int' | 'boolean' | 'array' | 'object';\n\n// ── The integer grammar ─────────────────────────────────────────────────────\n\nexport interface PositiveIntOptions {\n /** Smallest accepted value (default 1). Anything below falls back. */\n min?: number;\n /** Largest accepted value. Applied to the PARSED value AND to the fallback. */\n max?: number;\n}\n\n/**\n * THE integer grammar for URL params, env vars, and page sizes.\n *\n * `null`/`undefined`/`''` → fallback. Otherwise `Math.trunc(Number(raw))`, which\n * accepts `'12'`, `12`, `'12.9'` (→ 12) and `'0x10'` (→ 16), and rejects `'12abc'`\n * and `'abc'` (`NaN`) — the historical `parseInt` accepted `'12abc'` as 12 and\n * produced `NaN` for `'abc'`. A value below `min` falls back. The RESULT (parsed\n * value or fallback alike) is clamped to `max`, so \"never exceeds max\" has one owner.\n */\nexport function positiveInt<TFallback = number>(\n raw: string | number | null | undefined,\n fallback: TFallback,\n { min = 1, max }: PositiveIntOptions = {},\n): number | TFallback {\n // The fallback is returned AS GIVEN — capping only ever applies to a value\n // parsed out of the input, so a caller with no default (`null`) gets its\n // sentinel back untouched instead of `Math.min(null, max)`.\n const capped = (v: number) => (max == null ? v : Math.min(v, max));\n if (raw === null || raw === undefined || raw === '') {\n return typeof fallback === 'number' ? capped(fallback) : fallback;\n }\n const n = Math.trunc(Number(raw));\n if (!Number.isFinite(n) || n < min) {\n return typeof fallback === 'number' ? capped(fallback) : fallback;\n }\n return capped(n);\n}\n\n/** Ceilings every paged surface shares. */\nexport const PAGE_PARAM_LIMITS = { maxPage: 10_000, maxPageSize: 100 } as const;\n\n/** Total pages for a row count. Floors at 1, so an empty result is still \"page 1 of 1\". */\nexport function pageCount(total: number, pageSize: number): number {\n if (!Number.isFinite(total) || !Number.isFinite(pageSize) || pageSize <= 0) return 1;\n return Math.max(1, Math.ceil(total / pageSize));\n}\n\n/** Clamp a requested page into `1..totalPages`. */\nexport function clampPageToTotal(page: number, totalPages: number): number {\n return Math.min(page, Math.max(1, totalPages));\n}\n\n// ── Schema ──────────────────────────────────────────────────────────────────\n\ntype OutputTypeMap = {\n string: string;\n number: number;\n int: number;\n boolean: boolean;\n array: string[];\n object: Record<string, unknown>;\n};\n\nexport type OutputTypeForJSType<T extends JSType> = OutputTypeMap[T];\n\nexport interface BaseParamConfig<T extends JSType = JSType> {\n type: T;\n default?: OutputTypeMap[T];\n required?: boolean;\n}\n\n/** An integer parameter with an optional floor and ceiling. */\nexport interface IntParamConfig extends BaseParamConfig<'int'> {\n min?: number;\n max?: number;\n}\n\nexport type ParamConfig<T extends JSType = JSType> = T extends 'int' ? IntParamConfig : BaseParamConfig<T>;\n\nexport type ParamSchema = Record<string, ParamConfig>;\n\n/** Identity helper that preserves literal types through a schema object. */\nexport function defineParamSchema<T extends ParamSchema>(schema: T): T {\n return schema;\n}\n\n/**\n * Build a `Record<K, V>` from a key tuple. Keeps `keyof` EXACT when spreading a\n * shared config across derived keys (an inline `Object.fromEntries` widens to\n * `Record<string, V>` and loses the key union).\n */\nexport function fromKeys<K extends string, V>(keys: readonly K[], value: V): Record<K, V> {\n return Object.fromEntries(keys.map(k => [k, value])) as Record<K, V>;\n}\n\n// ── Reading ─────────────────────────────────────────────────────────────────\n\n/** Anything a URL can be read from: `URLSearchParams`, Next's `searchParams`, or a plain record. */\nexport type ParamInput = URLSearchParams | Record<string, string | string[] | undefined> | null | undefined;\n\n/** THE first-value rule for a duplicated key (`?a=1&a=2` reads as `1`). */\nexport function firstParamValue(input: ParamInput, key: string): string | undefined {\n if (!input) return undefined;\n if (input instanceof URLSearchParams) return input.get(key) ?? undefined;\n const value = input[key];\n return Array.isArray(value) ? value[0] : value;\n}\n\n/** Every value for a key (arrays read repeated params, then a `,`-joined single value). */\nfunction allParamValues(input: ParamInput, key: string): string[] {\n if (!input) return [];\n const raw = input instanceof URLSearchParams ? input.getAll(key) : input[key];\n const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];\n if (list.length === 1 && list[0]?.includes(',')) return list[0].split(',').filter(Boolean);\n return list.filter(v => v !== undefined && v !== '');\n}\n\nexport interface ParseSchemaOptions {\n /**\n * What an ABSENT scalar with no declared `default` resolves to.\n * `'undefined'` (default) keeps today's behaviour; `'null'` is the explicit\n * \"this filter is unset\" spelling that survives JSON and `URLSearchParams`.\n */\n absent?: 'undefined' | 'null';\n}\n\nexport type AbsentValue<Opts extends ParseSchemaOptions | undefined> = Opts extends { absent: 'null' }\n ? null\n : undefined;\n\n/**\n * The parsed shape of a schema: keys with a `default` and every array key are\n * non-nullable; an undefaulted scalar carries the caller's absent value.\n */\nexport type InferParsedParams<TSchema extends ParamSchema, Absent = undefined> = {\n [K in keyof TSchema]: TSchema[K] extends { type: 'array' }\n ? string[]\n : TSchema[K] extends { default: infer D }\n ? D\n : TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T> | Absent\n : never\n : never;\n};\n\nfunction coerceScalar(raw: string | undefined, config: ParamConfig): unknown {\n if (raw === undefined || raw === '') return undefined;\n switch (config.type) {\n case 'int': {\n const int = config;\n // ONE integer grammar, default or not. Without a declared default the\n // fallback is `undefined` (an unparseable value is absent), which\n // `positiveInt` returns untouched — the branch used to open-code the\n // truncation, the min floor and the max clamp a second time.\n return positiveInt<undefined>(raw, int.default as undefined, { min: int.min, max: int.max });\n }\n case 'number': {\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n }\n case 'boolean':\n return raw === 'true' || raw === '1';\n case 'object':\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return undefined;\n }\n default:\n return raw;\n }\n}\n\n/**\n * Parse a URL into a schema's shape. THE reader — the server route and the\n * client hook both call this, so a URL means exactly one thing.\n *\n * PRECEDENCE: an absent or empty SCALAR takes the declared `default` when there\n * is one, else the `absent` value. ARRAY keys never take `absent`: they are `[]`.\n */\nexport function parseSchemaParams<TSchema extends ParamSchema, Opts extends ParseSchemaOptions = Record<string, never>>(\n schema: TSchema,\n input: ParamInput,\n options?: Opts,\n): InferParsedParams<TSchema, AbsentValue<Opts>> {\n const absent = options?.absent === 'null' ? null : undefined;\n const out: Record<string, unknown> = {};\n for (const [key, config] of Object.entries(schema)) {\n if (config.type === 'array') {\n const values = allParamValues(input, key);\n out[key] = values.length > 0 ? values : (config.default ?? []);\n continue;\n }\n const coerced = coerceScalar(firstParamValue(input, key), config);\n out[key] = coerced !== undefined ? coerced : (config.default ?? absent);\n }\n return out as InferParsedParams<TSchema, AbsentValue<Opts>>;\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────\n\n/**\n * A schema entry OR a legacy flattened param (which spells its default\n * `defaultValue`).\n *\n * A UNION, not a bag of two optional fields: with only `default?` and\n * `defaultValue?` this is a WEAK TYPE, and TypeScript rejects any argument\n * sharing neither — including `{ type: 'string' }`, the single most common\n * schema entry there is. Naming the real config shapes fixes that without an\n * index signature, which `FlattenedParam` (an interface) could never satisfy.\n */\ntype OmissionConfig = ParamConfig | { default?: unknown; defaultValue?: unknown };\n\n/**\n * THE omission rule: `null`, `undefined`, `''`, `[]`, and a value equal to the\n * declared default are left out, so a URL only ever carries what differs.\n */\nexport function shouldIncludeInUrl(value: unknown, config: OmissionConfig | undefined): boolean {\n if (value === null || value === undefined) return false;\n if (Array.isArray(value) && value.length === 0) return false;\n if (value === '') return false;\n // `in` narrowing rather than a cast: the union's members genuinely differ —\n // a schema entry spells its default `default`, a flattened param spells it\n // `defaultValue`, and neither is obliged to carry the other's field.\n const fromDefault = config && 'default' in config ? config.default : undefined;\n const fromLegacy = config && 'defaultValue' in config ? config.defaultValue : undefined;\n const declared = fromDefault !== undefined ? fromDefault : fromLegacy;\n if (declared !== undefined && value === declared) return false;\n return true;\n}\n\nexport interface CreateSearchParamsOptions {\n /** Join arrays with this separator instead of repeating the key (`;` for the hub's list APIs). */\n arrayJoin?: string;\n}\n\n/** Build `URLSearchParams` from a value record (arrays repeat the key by default). */\nexport function createSearchParams(\n params: Record<string, unknown>,\n { arrayJoin }: CreateSearchParamsOptions = {},\n): URLSearchParams {\n const search = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n // The emptiness half of the omission rule has ONE owner. (No config here:\n // callers that must also drop schema DEFAULTS filter through\n // `shouldIncludeInUrl` with the config first — see `serializeSchemaParams`.)\n if (!shouldIncludeInUrl(value, undefined)) continue;\n if (Array.isArray(value)) {\n // Empty ELEMENTS are dropped, and an array that filters to nothing is\n // absent (a cleared multi-select must not leave `?tags=` behind).\n const items = value.filter(v => v !== null && v !== undefined && v !== '');\n if (items.length === 0) continue;\n if (arrayJoin) search.set(key, items.join(arrayJoin));\n else for (const item of items) search.append(key, String(item));\n continue;\n }\n if (typeof value === 'object') {\n search.set(key, JSON.stringify(value));\n continue;\n }\n search.set(key, String(value));\n }\n return search;\n}\n\n/** Serialize params to a query string, omitting everything `shouldIncludeInUrl` rejects. */\nexport function serializeSchemaParams<TSchema extends ParamSchema>(\n schema: TSchema,\n params: Record<string, unknown>,\n options?: CreateSearchParamsOptions,\n): string {\n const kept: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(params)) {\n if (shouldIncludeInUrl(value, schema[key])) kept[key] = value;\n }\n return createSearchParams(kept, options).toString();\n}\n\n/** Append a query string to a path, or return the path unchanged when it is empty. */\nexport function withQuery(path: string, queryString: string | null | undefined): string {\n return queryString ? `${path}?${queryString}` : path;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/search-params.ts"],"names":[],"mappings":"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","file":"search-params.js","sourcesContent":["/**\n * URL parameter schema engine — the ONE grammar for every URL/API integer,\n * the ONE omission rule, the ONE serializer.\n *\n * Hoisted out of `hooks/state/` so SERVER code (route handlers, DALs) and the\n * CLIENT hook parse the same URL with the same function: a param contract that\n * disagrees across the wire is the bug class this removes. JSX-free leaf with\n * its own `exports` subpath; `hooks/state/*` re-exports every name it used to own.\n */\n\n/** JavaScript types a URL parameter can carry. `int` is a whole number with a floor. */\nexport type JSType = 'string' | 'number' | 'int' | 'boolean' | 'array' | 'object';\n\n// ── The integer grammar ─────────────────────────────────────────────────────\n\nexport interface PositiveIntOptions {\n /** Smallest accepted value (default 1). Anything below falls back. */\n min?: number;\n /** Largest accepted value. Applied to the PARSED value AND to the fallback. */\n max?: number;\n}\n\n/**\n * THE integer grammar for URL params, env vars, and page sizes.\n *\n * `null`/`undefined`/`''` → fallback. Otherwise `Math.trunc(Number(raw))`, which\n * accepts `'12'`, `12`, `'12.9'` (→ 12) and `'0x10'` (→ 16), and rejects `'12abc'`\n * and `'abc'` (`NaN`) — the historical `parseInt` accepted `'12abc'` as 12 and\n * produced `NaN` for `'abc'`. A value below `min` falls back. The RESULT (parsed\n * value or fallback alike) is clamped to `max`, so \"never exceeds max\" has one owner.\n */\nexport function positiveInt<TFallback = number>(\n raw: string | number | null | undefined,\n fallback: TFallback,\n { min = 1, max }: PositiveIntOptions = {},\n): number | TFallback {\n // The fallback is returned AS GIVEN — capping only ever applies to a value\n // parsed out of the input, so a caller with no default (`null`) gets its\n // sentinel back untouched instead of `Math.min(null, max)`.\n const capped = (v: number) => (max == null ? v : Math.min(v, max));\n if (raw === null || raw === undefined || raw === '') {\n return typeof fallback === 'number' ? (capped(fallback) as number | TFallback) : fallback;\n }\n const n = Math.trunc(Number(raw));\n if (!Number.isFinite(n) || n < min) {\n return typeof fallback === 'number' ? (capped(fallback) as number | TFallback) : fallback;\n }\n return capped(n);\n}\n\n/** Ceilings every paged surface shares. */\nexport const PAGE_PARAM_LIMITS = { maxPage: 10_000, maxPageSize: 100 } as const;\n\n/** Total pages for a row count. Floors at 1, so an empty result is still \"page 1 of 1\". */\nexport function pageCount(total: number, pageSize: number): number {\n if (!Number.isFinite(total) || !Number.isFinite(pageSize) || pageSize <= 0) return 1;\n return Math.max(1, Math.ceil(total / pageSize));\n}\n\n/** Clamp a requested page into `1..totalPages`. */\nexport function clampPageToTotal(page: number, totalPages: number): number {\n return Math.min(page, Math.max(1, totalPages));\n}\n\n// ── Schema ──────────────────────────────────────────────────────────────────\n\ntype OutputTypeMap = {\n string: string;\n number: number;\n int: number;\n boolean: boolean;\n array: string[];\n object: Record<string, unknown>;\n};\n\nexport type OutputTypeForJSType<T extends JSType> = OutputTypeMap[T];\n\nexport interface BaseParamConfig<T extends JSType = JSType> {\n type: T;\n default?: OutputTypeMap[T];\n required?: boolean;\n}\n\n/** An integer parameter with an optional floor and ceiling. */\nexport interface IntParamConfig extends BaseParamConfig<'int'> {\n min?: number;\n max?: number;\n}\n\nexport type ParamConfig<T extends JSType = JSType> = T extends 'int' ? IntParamConfig : BaseParamConfig<T>;\n\nexport type ParamSchema = Record<string, ParamConfig>;\n\n/** Identity helper that preserves literal types through a schema object. */\nexport function defineParamSchema<T extends ParamSchema>(schema: T): T {\n return schema;\n}\n\n/**\n * Build a `Record<K, V>` from a key tuple. Keeps `keyof` EXACT when spreading a\n * shared config across derived keys (an inline `Object.fromEntries` widens to\n * `Record<string, V>` and loses the key union).\n */\nexport function fromKeys<K extends string, V>(keys: readonly K[], value: V): Record<K, V> {\n return Object.fromEntries(keys.map(k => [k, value])) as Record<K, V>;\n}\n\n// ── Reading ─────────────────────────────────────────────────────────────────\n\n/** Anything a URL can be read from: `URLSearchParams`, Next's `searchParams`, or a plain record. */\nexport type ParamInput = URLSearchParams | Record<string, string | string[] | undefined> | null | undefined;\n\n/** THE first-value rule for a duplicated key (`?a=1&a=2` reads as `1`). */\nexport function firstParamValue(input: ParamInput, key: string): string | undefined {\n if (!input) return undefined;\n if (input instanceof URLSearchParams) return input.get(key) ?? undefined;\n const value = input[key];\n return Array.isArray(value) ? value[0] : value;\n}\n\n/** Every value for a key (arrays read repeated params, then a `,`-joined single value). */\nfunction allParamValues(input: ParamInput, key: string): string[] {\n if (!input) return [];\n const raw = input instanceof URLSearchParams ? input.getAll(key) : input[key];\n const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];\n if (list.length === 1 && list[0]?.includes(',')) return list[0].split(',').filter(Boolean);\n return list.filter(v => v !== undefined && v !== '');\n}\n\nexport interface ParseSchemaOptions {\n /**\n * What an ABSENT scalar with no declared `default` resolves to.\n * `'undefined'` (default) keeps today's behaviour; `'null'` is the explicit\n * \"this filter is unset\" spelling that survives JSON and `URLSearchParams`.\n */\n absent?: 'undefined' | 'null';\n}\n\nexport type AbsentValue<Opts extends ParseSchemaOptions | undefined> = Opts extends { absent: 'null' }\n ? null\n : undefined;\n\n/**\n * The parsed shape of a schema: keys with a `default` and every array key are\n * non-nullable; an undefaulted scalar carries the caller's absent value.\n */\nexport type InferParsedParams<TSchema extends ParamSchema, Absent = undefined> = {\n [K in keyof TSchema]: TSchema[K] extends { type: 'array' }\n ? string[]\n : TSchema[K] extends { default: infer D }\n ? D\n : TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T> | Absent\n : never\n : never;\n};\n\nfunction coerceScalar(raw: string | undefined, config: ParamConfig): unknown {\n if (raw === undefined || raw === '') return undefined;\n switch (config.type) {\n case 'int': {\n const int = config as IntParamConfig;\n // ONE integer grammar, default or not. Without a declared default the\n // fallback is `undefined` (an unparseable value is absent), which\n // `positiveInt` returns untouched — the branch used to open-code the\n // truncation, the min floor and the max clamp a second time.\n return positiveInt<undefined>(raw, int.default as undefined, { min: int.min, max: int.max });\n }\n case 'number': {\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n }\n case 'boolean':\n return raw === 'true' || raw === '1';\n case 'object':\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return undefined;\n }\n default:\n return raw;\n }\n}\n\n/**\n * Parse a URL into a schema's shape. THE reader — the server route and the\n * client hook both call this, so a URL means exactly one thing.\n *\n * PRECEDENCE: an absent or empty SCALAR takes the declared `default` when there\n * is one, else the `absent` value. ARRAY keys never take `absent`: they are `[]`.\n */\nexport function parseSchemaParams<TSchema extends ParamSchema, Opts extends ParseSchemaOptions = Record<string, never>>(\n schema: TSchema,\n input: ParamInput,\n options?: Opts,\n): InferParsedParams<TSchema, AbsentValue<Opts>> {\n const absent = options?.absent === 'null' ? null : undefined;\n const out: Record<string, unknown> = {};\n for (const [key, config] of Object.entries(schema)) {\n if (config.type === 'array') {\n const values = allParamValues(input, key);\n out[key] = values.length > 0 ? values : ((config.default as string[] | undefined) ?? []);\n continue;\n }\n const coerced = coerceScalar(firstParamValue(input, key), config);\n out[key] = coerced !== undefined ? coerced : (config.default ?? absent);\n }\n return out as InferParsedParams<TSchema, AbsentValue<Opts>>;\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────\n\n/**\n * A schema entry OR a legacy flattened param (which spells its default\n * `defaultValue`).\n *\n * A UNION, not a bag of two optional fields: with only `default?` and\n * `defaultValue?` this is a WEAK TYPE, and TypeScript rejects any argument\n * sharing neither — including `{ type: 'string' }`, the single most common\n * schema entry there is. Naming the real config shapes fixes that without an\n * index signature, which `FlattenedParam` (an interface) could never satisfy.\n */\ntype OmissionConfig = ParamConfig | { default?: unknown; defaultValue?: unknown };\n\n/**\n * THE omission rule: `null`, `undefined`, `''`, `[]`, and a value equal to the\n * declared default are left out, so a URL only ever carries what differs.\n */\nexport function shouldIncludeInUrl(value: unknown, config: OmissionConfig | undefined): boolean {\n if (value === null || value === undefined) return false;\n if (Array.isArray(value) && value.length === 0) return false;\n if (value === '') return false;\n // `in` narrowing rather than a cast: the union's members genuinely differ —\n // a schema entry spells its default `default`, a flattened param spells it\n // `defaultValue`, and neither is obliged to carry the other's field.\n const fromDefault = config && 'default' in config ? config.default : undefined;\n const fromLegacy = config && 'defaultValue' in config ? config.defaultValue : undefined;\n const declared = fromDefault !== undefined ? fromDefault : fromLegacy;\n if (declared !== undefined && value === declared) return false;\n return true;\n}\n\nexport interface CreateSearchParamsOptions {\n /** Join arrays with this separator instead of repeating the key (`;` for the hub's list APIs). */\n arrayJoin?: string;\n}\n\n/** Build `URLSearchParams` from a value record (arrays repeat the key by default). */\nexport function createSearchParams(\n params: Record<string, unknown>,\n { arrayJoin }: CreateSearchParamsOptions = {},\n): URLSearchParams {\n const search = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n // The emptiness half of the omission rule has ONE owner. (No config here:\n // callers that must also drop schema DEFAULTS filter through\n // `shouldIncludeInUrl` with the config first — see `serializeSchemaParams`.)\n if (!shouldIncludeInUrl(value, undefined)) continue;\n if (Array.isArray(value)) {\n // Empty ELEMENTS are dropped, and an array that filters to nothing is\n // absent (a cleared multi-select must not leave `?tags=` behind).\n const items = value.filter(v => v !== null && v !== undefined && v !== '');\n if (items.length === 0) continue;\n if (arrayJoin) search.set(key, items.join(arrayJoin));\n else for (const item of items) search.append(key, String(item));\n continue;\n }\n if (typeof value === 'object') {\n search.set(key, JSON.stringify(value));\n continue;\n }\n search.set(key, String(value));\n }\n return search;\n}\n\n/** Serialize params to a query string, omitting everything `shouldIncludeInUrl` rejects. */\nexport function serializeSchemaParams<TSchema extends ParamSchema>(\n schema: TSchema,\n params: Record<string, unknown>,\n options?: CreateSearchParamsOptions,\n): string {\n const kept: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(params)) {\n if (shouldIncludeInUrl(value, schema[key])) kept[key] = value;\n }\n return createSearchParams(kept, options).toString();\n}\n\n/** Append a query string to a path, or return the path unchanged when it is empty. */\nexport function withQuery(path: string, queryString: string | null | undefined): string {\n return queryString ? `${path}?${queryString}` : path;\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/search-params.ts"],"names":[],"mappings":"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","file":"search-params.js","sourcesContent":["/**\n * URL parameter schema engine — the ONE grammar for every URL/API integer,\n * the ONE omission rule, the ONE serializer.\n *\n * Hoisted out of `hooks/state/` so SERVER code (route handlers, DALs) and the\n * CLIENT hook parse the same URL with the same function: a param contract that\n * disagrees across the wire is the bug class this removes. JSX-free leaf with\n * its own `exports` subpath; `hooks/state/*` re-exports every name it used to own.\n */\n\n/** JavaScript types a URL parameter can carry. `int` is a whole number with a floor. */\nexport type JSType = 'string' | 'number' | 'int' | 'boolean' | 'array' | 'object';\n\n// ── The integer grammar ─────────────────────────────────────────────────────\n\nexport interface PositiveIntOptions {\n /** Smallest accepted value (default 1). Anything below falls back. */\n min?: number;\n /** Largest accepted value. Applied to the PARSED value AND to the fallback. */\n max?: number;\n}\n\n/**\n * THE integer grammar for URL params, env vars, and page sizes.\n *\n * `null`/`undefined`/`''` → fallback. Otherwise `Math.trunc(Number(raw))`, which\n * accepts `'12'`, `12`, `'12.9'` (→ 12) and `'0x10'` (→ 16), and rejects `'12abc'`\n * and `'abc'` (`NaN`) — the historical `parseInt` accepted `'12abc'` as 12 and\n * produced `NaN` for `'abc'`. A value below `min` falls back. The RESULT (parsed\n * value or fallback alike) is clamped to `max`, so \"never exceeds max\" has one owner.\n */\nexport function positiveInt<TFallback = number>(\n raw: string | number | null | undefined,\n fallback: TFallback,\n { min = 1, max }: PositiveIntOptions = {},\n): number | TFallback {\n // The fallback is returned AS GIVEN — capping only ever applies to a value\n // parsed out of the input, so a caller with no default (`null`) gets its\n // sentinel back untouched instead of `Math.min(null, max)`.\n const capped = (v: number) => (max == null ? v : Math.min(v, max));\n if (raw === null || raw === undefined || raw === '') {\n return typeof fallback === 'number' ? capped(fallback) : fallback;\n }\n const n = Math.trunc(Number(raw));\n if (!Number.isFinite(n) || n < min) {\n return typeof fallback === 'number' ? capped(fallback) : fallback;\n }\n return capped(n);\n}\n\n/** Ceilings every paged surface shares. */\nexport const PAGE_PARAM_LIMITS = { maxPage: 10_000, maxPageSize: 100 } as const;\n\n/** Total pages for a row count. Floors at 1, so an empty result is still \"page 1 of 1\". */\nexport function pageCount(total: number, pageSize: number): number {\n if (!Number.isFinite(total) || !Number.isFinite(pageSize) || pageSize <= 0) return 1;\n return Math.max(1, Math.ceil(total / pageSize));\n}\n\n/** Clamp a requested page into `1..totalPages`. */\nexport function clampPageToTotal(page: number, totalPages: number): number {\n return Math.min(page, Math.max(1, totalPages));\n}\n\n// ── Schema ──────────────────────────────────────────────────────────────────\n\ntype OutputTypeMap = {\n string: string;\n number: number;\n int: number;\n boolean: boolean;\n array: string[];\n object: Record<string, unknown>;\n};\n\nexport type OutputTypeForJSType<T extends JSType> = OutputTypeMap[T];\n\nexport interface BaseParamConfig<T extends JSType = JSType> {\n type: T;\n default?: OutputTypeMap[T];\n required?: boolean;\n}\n\n/** An integer parameter with an optional floor and ceiling. */\nexport interface IntParamConfig extends BaseParamConfig<'int'> {\n min?: number;\n max?: number;\n}\n\nexport type ParamConfig<T extends JSType = JSType> = T extends 'int' ? IntParamConfig : BaseParamConfig<T>;\n\nexport type ParamSchema = Record<string, ParamConfig>;\n\n/** Identity helper that preserves literal types through a schema object. */\nexport function defineParamSchema<T extends ParamSchema>(schema: T): T {\n return schema;\n}\n\n/**\n * Build a `Record<K, V>` from a key tuple. Keeps `keyof` EXACT when spreading a\n * shared config across derived keys (an inline `Object.fromEntries` widens to\n * `Record<string, V>` and loses the key union).\n */\nexport function fromKeys<K extends string, V>(keys: readonly K[], value: V): Record<K, V> {\n return Object.fromEntries(keys.map(k => [k, value])) as Record<K, V>;\n}\n\n// ── Reading ─────────────────────────────────────────────────────────────────\n\n/** Anything a URL can be read from: `URLSearchParams`, Next's `searchParams`, or a plain record. */\nexport type ParamInput = URLSearchParams | Record<string, string | string[] | undefined> | null | undefined;\n\n/** THE first-value rule for a duplicated key (`?a=1&a=2` reads as `1`). */\nexport function firstParamValue(input: ParamInput, key: string): string | undefined {\n if (!input) return undefined;\n if (input instanceof URLSearchParams) return input.get(key) ?? undefined;\n const value = input[key];\n return Array.isArray(value) ? value[0] : value;\n}\n\n/** Every value for a key (arrays read repeated params, then a `,`-joined single value). */\nfunction allParamValues(input: ParamInput, key: string): string[] {\n if (!input) return [];\n const raw = input instanceof URLSearchParams ? input.getAll(key) : input[key];\n const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];\n if (list.length === 1 && list[0]?.includes(',')) return list[0].split(',').filter(Boolean);\n return list.filter(v => v !== undefined && v !== '');\n}\n\nexport interface ParseSchemaOptions {\n /**\n * What an ABSENT scalar with no declared `default` resolves to.\n * `'undefined'` (default) keeps today's behaviour; `'null'` is the explicit\n * \"this filter is unset\" spelling that survives JSON and `URLSearchParams`.\n */\n absent?: 'undefined' | 'null';\n}\n\nexport type AbsentValue<Opts extends ParseSchemaOptions | undefined> = Opts extends { absent: 'null' }\n ? null\n : undefined;\n\n/**\n * The parsed shape of a schema: keys with a `default` and every array key are\n * non-nullable; an undefaulted scalar carries the caller's absent value.\n */\nexport type InferParsedParams<TSchema extends ParamSchema, Absent = undefined> = {\n [K in keyof TSchema]: TSchema[K] extends { type: 'array' }\n ? string[]\n : TSchema[K] extends { default: infer D }\n ? D\n : TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T> | Absent\n : never\n : never;\n};\n\nfunction coerceScalar(raw: string | undefined, config: ParamConfig): unknown {\n if (raw === undefined || raw === '') return undefined;\n switch (config.type) {\n case 'int': {\n const int = config;\n // ONE integer grammar, default or not. Without a declared default the\n // fallback is `undefined` (an unparseable value is absent), which\n // `positiveInt` returns untouched — the branch used to open-code the\n // truncation, the min floor and the max clamp a second time.\n return positiveInt<undefined>(raw, int.default as undefined, { min: int.min, max: int.max });\n }\n case 'number': {\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n }\n case 'boolean':\n return raw === 'true' || raw === '1';\n case 'object':\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return undefined;\n }\n default:\n return raw;\n }\n}\n\n/**\n * Parse a URL into a schema's shape. THE reader — the server route and the\n * client hook both call this, so a URL means exactly one thing.\n *\n * PRECEDENCE: an absent or empty SCALAR takes the declared `default` when there\n * is one, else the `absent` value. ARRAY keys never take `absent`: they are `[]`.\n */\nexport function parseSchemaParams<TSchema extends ParamSchema, Opts extends ParseSchemaOptions = Record<string, never>>(\n schema: TSchema,\n input: ParamInput,\n options?: Opts,\n): InferParsedParams<TSchema, AbsentValue<Opts>> {\n const absent = options?.absent === 'null' ? null : undefined;\n const out: Record<string, unknown> = {};\n for (const [key, config] of Object.entries(schema)) {\n if (config.type === 'array') {\n const values = allParamValues(input, key);\n out[key] = values.length > 0 ? values : (config.default ?? []);\n continue;\n }\n const coerced = coerceScalar(firstParamValue(input, key), config);\n out[key] = coerced !== undefined ? coerced : (config.default ?? absent);\n }\n return out as InferParsedParams<TSchema, AbsentValue<Opts>>;\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────\n\n/**\n * A schema entry OR a legacy flattened param (which spells its default\n * `defaultValue`).\n *\n * A UNION, not a bag of two optional fields: with only `default?` and\n * `defaultValue?` this is a WEAK TYPE, and TypeScript rejects any argument\n * sharing neither — including `{ type: 'string' }`, the single most common\n * schema entry there is. Naming the real config shapes fixes that without an\n * index signature, which `FlattenedParam` (an interface) could never satisfy.\n */\ntype OmissionConfig = ParamConfig | { default?: unknown; defaultValue?: unknown };\n\n/**\n * THE omission rule: `null`, `undefined`, `''`, `[]`, and a value equal to the\n * declared default are left out, so a URL only ever carries what differs.\n */\nexport function shouldIncludeInUrl(value: unknown, config: OmissionConfig | undefined): boolean {\n if (value === null || value === undefined) return false;\n if (Array.isArray(value) && value.length === 0) return false;\n if (value === '') return false;\n // `in` narrowing rather than a cast: the union's members genuinely differ —\n // a schema entry spells its default `default`, a flattened param spells it\n // `defaultValue`, and neither is obliged to carry the other's field.\n const fromDefault = config && 'default' in config ? config.default : undefined;\n const fromLegacy = config && 'defaultValue' in config ? config.defaultValue : undefined;\n const declared = fromDefault !== undefined ? fromDefault : fromLegacy;\n if (declared !== undefined && value === declared) return false;\n return true;\n}\n\nexport interface CreateSearchParamsOptions {\n /** Join arrays with this separator instead of repeating the key (`;` for the hub's list APIs). */\n arrayJoin?: string;\n}\n\n/** Build `URLSearchParams` from a value record (arrays repeat the key by default). */\nexport function createSearchParams(\n params: Record<string, unknown>,\n { arrayJoin }: CreateSearchParamsOptions = {},\n): URLSearchParams {\n const search = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n // The emptiness half of the omission rule has ONE owner. (No config here:\n // callers that must also drop schema DEFAULTS filter through\n // `shouldIncludeInUrl` with the config first — see `serializeSchemaParams`.)\n if (!shouldIncludeInUrl(value, undefined)) continue;\n if (Array.isArray(value)) {\n // Empty ELEMENTS are dropped, and an array that filters to nothing is\n // absent (a cleared multi-select must not leave `?tags=` behind).\n const items = value.filter(v => v !== null && v !== undefined && v !== '');\n if (items.length === 0) continue;\n if (arrayJoin) search.set(key, items.join(arrayJoin));\n else for (const item of items) search.append(key, String(item));\n continue;\n }\n if (typeof value === 'object') {\n search.set(key, JSON.stringify(value));\n continue;\n }\n search.set(key, String(value));\n }\n return search;\n}\n\n/** Serialize params to a query string, omitting everything `shouldIncludeInUrl` rejects. */\nexport function serializeSchemaParams<TSchema extends ParamSchema>(\n schema: TSchema,\n params: Record<string, unknown>,\n options?: CreateSearchParamsOptions,\n): string {\n const kept: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(params)) {\n if (shouldIncludeInUrl(value, schema[key])) kept[key] = value;\n }\n return createSearchParams(kept, options).toString();\n}\n\n/** Append a query string to a path, or return the path unchanged when it is empty. */\nexport function withQuery(path: string, queryString: string | null | undefined): string {\n return queryString ? `${path}?${queryString}` : path;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.591",
3
+ "version": "0.0.592-snapshot.20260905013229",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,170 @@
1
+ import { createColumnHelper } from '@tanstack/react-table';
2
+ import { render } from '@testing-library/react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { DataTable } from '../components/ui/data-table';
5
+ import { useDataTable } from '../components/ui/data-table/use-data-table';
6
+ import { EntityImage } from '../components/ui/entity-image';
7
+
8
+ /**
9
+ * `minRows` promises a stable table height. It kept that promise only for a
10
+ * table whose rows are the design height — a table stating its own height got
11
+ * pad rows and a skeleton at the design height instead, so a short page came up
12
+ * short by the difference, per missing row.
13
+ *
14
+ * These lock the one thing that makes the promise true: EVERY row slot a table
15
+ * can draw — a real row, a pad row, a skeleton row — reserves the SAME height.
16
+ */
17
+
18
+ interface Row {
19
+ id: string;
20
+ name: string;
21
+ }
22
+
23
+ const columnHelper = createColumnHelper<Row>();
24
+ const columns = [columnHelper.accessor('name', { id: 'name', header: 'Name' })];
25
+
26
+ const ROW_H = 'h-[112px]';
27
+
28
+ function Table({
29
+ data,
30
+ loading = false,
31
+ rowHeightClassName,
32
+ }: {
33
+ data: Row[];
34
+ loading?: boolean;
35
+ rowHeightClassName?: string;
36
+ }) {
37
+ const table = useDataTable({ data, columns });
38
+ return (
39
+ <DataTable table={table}>
40
+ <DataTable.Body<Row>
41
+ loading={loading}
42
+ minRows={4}
43
+ skeletonRows={4}
44
+ autoHeight
45
+ rowHeightClassName={rowHeightClassName}
46
+ />
47
+ </DataTable>
48
+ );
49
+ }
50
+
51
+ /** The same table, but every row carries a sub-row beneath its cells. */
52
+ function TableWithSubRows({ data, rowHeightClassName }: { data: Row[]; rowHeightClassName?: string }) {
53
+ const table = useDataTable({ data, columns });
54
+ return (
55
+ <DataTable table={table}>
56
+ <DataTable.Body<Row>
57
+ minRows={4}
58
+ skeletonRows={4}
59
+ rowHeightClassName={rowHeightClassName}
60
+ renderSubRow={() => <div style={{ height: 96 }}>sub</div>}
61
+ />
62
+ </DataTable>
63
+ );
64
+ }
65
+
66
+ const rows = (n: number): Row[] => Array.from({ length: n }, (_, i) => ({ id: String(i), name: `Row ${i}` }));
67
+
68
+ /**
69
+ * Every explicit height utility in the rendered markup.
70
+ *
71
+ * Read off the markup rather than through a selector, because the height a row
72
+ * slot reserves is exactly what ships in the class attribute — and matching an
73
+ * arbitrary-value class through `querySelectorAll` is its own trap.
74
+ */
75
+ function heightClasses(container: HTMLElement): string[] {
76
+ return [...container.innerHTML.matchAll(/\b(?:md:)?(?:min-)?h-\[[^\]\s"]+\]/g)].map(m => m[0]);
77
+ }
78
+
79
+ /** The class list of every row-card slot (real or pad) in the rendered markup. */
80
+ function slotClasses(container: HTMLElement): string[] {
81
+ return [...container.innerHTML.matchAll(/class="([^"]*\brounded-md\b[^"]*)"/g)].map(m => m[1]);
82
+ }
83
+
84
+ describe('DataTable row height', () => {
85
+ it('reserves the SAME height for real rows and the pad rows beside them', () => {
86
+ // Two real rows in a table promising four: two pad rows make up the rest.
87
+ const { container } = render(<Table data={rows(2)} rowHeightClassName={ROW_H} />);
88
+ const heights = heightClasses(container);
89
+
90
+ expect(heights.length).toBeGreaterThan(0);
91
+ // Not "most of them" — every single slot, or the table's height moves with
92
+ // how many results the page happens to hold.
93
+ expect(new Set(heights)).toEqual(new Set([ROW_H]));
94
+ });
95
+
96
+ it('reserves that height in the SKELETON too, so loading and loaded agree', () => {
97
+ const { container } = render(<Table data={[]} loading rowHeightClassName={ROW_H} />);
98
+ expect(new Set(heightClasses(container))).toEqual(new Set([ROW_H]));
99
+ });
100
+
101
+ it('reserves it for an EMPTY board, which is when a collapse is most visible', () => {
102
+ const { container } = render(<Table data={[]} rowHeightClassName={ROW_H} />);
103
+ expect(new Set(heightClasses(container))).toEqual(new Set([ROW_H]));
104
+ });
105
+
106
+ it('REPLACES the height rather than merging, so no md: default survives', () => {
107
+ const { container } = render(<Table data={rows(1)} rowHeightClassName={ROW_H} />);
108
+ const html = container.innerHTML;
109
+ // The exact leftover that made a caller-supplied height hold on a phone and
110
+ // lose on a desktop.
111
+ expect(html).not.toContain('md:h-[78px]');
112
+ expect(html).not.toContain('md:min-h-[78px]');
113
+ });
114
+
115
+ it('gives a pad row the same BORDER BOX as the row it stands in for', () => {
116
+ // A real row card draws a 1px border on each side, so its outer block is
117
+ // the inner height plus 2. A pad row without one is 2px shorter — nothing
118
+ // per row, 54px across a 27-row remainder.
119
+ const { container } = render(<Table data={rows(2)} rowHeightClassName={ROW_H} />);
120
+ const slots = slotClasses(container);
121
+
122
+ expect(slots.length).toBeGreaterThanOrEqual(4);
123
+ expect(slots.every(cls => cls.split(/\s+/).includes('border'))).toBe(true);
124
+ });
125
+
126
+ it('sizes the ROW SLOT, so a sub-row does not make real rows outgrow pad rows', () => {
127
+ // The trap: a row with a sub-row is taller than its cells. Sizing the cells
128
+ // left every pad row short by exactly the sub-row — 96px each on a phone
129
+ // board — which is the same jump `minRows` exists to prevent, reintroduced
130
+ // one level down.
131
+ const { container } = render(<TableWithSubRows data={rows(2)} rowHeightClassName={ROW_H} />);
132
+ const slots = slotClasses(container);
133
+
134
+ expect(slots.length).toBeGreaterThanOrEqual(4);
135
+ // Every card — the two real rows and the two pad rows — carries the height.
136
+ expect(slots.every(cls => cls.split(/\s+/).includes(ROW_H))).toBe(true);
137
+ // And the cells inside a real row FILL it rather than restating it, or the
138
+ // card would be the cells plus the sub-row.
139
+ expect(container.innerHTML).toContain('flex-1');
140
+ });
141
+
142
+ it('keeps the design height when a table does not state one', () => {
143
+ const { container } = render(<Table data={rows(1)} />);
144
+ expect(container.innerHTML).toContain('md:h-[78px]');
145
+ });
146
+ });
147
+
148
+ describe('EntityImage size', () => {
149
+ it('SUBSTITUTES the default size, leaving no responsive half behind', () => {
150
+ const { container } = render(<EntityImage src="https://example.test/a.png" alt="A" sizeClassName="size-6" />);
151
+ const html = container.innerHTML;
152
+ expect(html).toContain('size-6');
153
+ // `h-4 w-6` through className used to leave this behind, which is the whole
154
+ // bug: 16px on a phone, 60px on a desktop.
155
+ expect(html).not.toContain('md:size-[60px]');
156
+ expect(html).not.toContain('size-[52px]');
157
+ });
158
+
159
+ it('applies the same substitution to the initials fallback', () => {
160
+ const { container } = render(<EntityImage src={null} alt="New York" sizeClassName="size-6" />);
161
+ const html = container.innerHTML;
162
+ expect(html).toContain('size-6');
163
+ expect(html).not.toContain('md:size-[60px]');
164
+ });
165
+
166
+ it('keeps the design size when a caller does not state one', () => {
167
+ const { container } = render(<EntityImage src={null} alt="New York" />);
168
+ expect(container.innerHTML).toContain('md:size-[60px]');
169
+ });
170
+ });
@@ -131,7 +131,7 @@ describe('common leaf', () => {
131
131
  expect(clamp(5, 1, 3)).toBe(3);
132
132
  expect(clamp(-5, 1, 3)).toBe(1);
133
133
  expect(pick({ a: 1, b: 2, c: 3 }, ['a', 'c'])).toEqual({ a: 1, c: 3 });
134
- expect(pick({ a: undefined } as { a: number | undefined }, ['a'])).toEqual({ a: undefined });
134
+ expect(pick({ a: undefined }, ['a'])).toEqual({ a: undefined });
135
135
  });
136
136
  it('states the no-client-cache rule once', () => {
137
137
  expect(NO_CLIENT_CACHE).toEqual({ staleTime: 0 });
@@ -30,6 +30,23 @@ export interface DataTableBodyProps<T = unknown> {
30
30
  * the row instead of clipping. Default keeps the fixed height.
31
31
  */
32
32
  autoHeight?: boolean;
33
+ /**
34
+ * REPLACES the design row height for THIS table's rows, its pad rows and its
35
+ * skeleton alike — one number, so they cannot disagree.
36
+ *
37
+ * Why a prop and not a className: `minRows` promises a stable table height,
38
+ * but its pad rows and the skeleton were hard-coded to the design height
39
+ * while a row passing `autoHeight` renders as tall as its content. A table
40
+ * with 112px rows padded a short page with 78px placeholders and came up
41
+ * 34px per missing row too short — on a 15-row page with 5 results, 340px of
42
+ * jump against every other page. Appending a height through `rowClassName`
43
+ * cannot fix it either: tailwind-merge drops the plain `h-[66px]` but keeps
44
+ * the `md:h-[78px]` beside it, so the override holds on a phone and loses on
45
+ * a desktop. Pass it here and every row slot in the table agrees.
46
+ *
47
+ * Responsive values belong in the string itself, e.g. `'h-[200px] md:h-[112px]'`.
48
+ */
49
+ rowHeightClassName?: string;
33
50
  /**
34
51
  * Click anywhere on a row (except elements with `data-no-row-click`). Prefer
35
52
  * `useCallback` to avoid breaking `React.memo` on rows.
@@ -68,6 +85,7 @@ export function DataTableBody<T = unknown>({
68
85
  rowClassName,
69
86
  compact,
70
87
  autoHeight,
88
+ rowHeightClassName,
71
89
  onRowClick,
72
90
  rowHref,
73
91
  minRows,
@@ -79,7 +97,7 @@ export function DataTableBody<T = unknown>({
79
97
  if (loading && rows.length === 0) {
80
98
  return (
81
99
  <div className={cn('flex w-full flex-col gap-[var(--spacing-system-xsf)]', className)}>
82
- <DataTableSkeleton rows={skeletonRows} />
100
+ <DataTableSkeleton rows={skeletonRows} rowHeightClassName={rowHeightClassName} />
83
101
  </div>
84
102
  );
85
103
  }
@@ -100,7 +118,7 @@ export function DataTableBody<T = unknown>({
100
118
  if (minRows) {
101
119
  return (
102
120
  <div className={cn('relative flex w-full flex-col gap-[var(--spacing-system-xsf)]', className)}>
103
- <PlaceholderRows count={minRows} />
121
+ <PlaceholderRows count={minRows} rowHeightClassName={rowHeightClassName} />
104
122
  <div className="absolute inset-0 flex items-center justify-center">{empty}</div>
105
123
  </div>
106
124
  );
@@ -125,12 +143,13 @@ export function DataTableBody<T = unknown>({
125
143
  href={href}
126
144
  compact={compact}
127
145
  autoHeight={autoHeight}
146
+ rowHeightClassName={rowHeightClassName}
128
147
  className={cls}
129
148
  subRow={renderSubRow?.(item)}
130
149
  />
131
150
  );
132
151
  })}
133
- {padCount > 0 && <PlaceholderRows count={padCount} />}
152
+ {padCount > 0 && <PlaceholderRows count={padCount} rowHeightClassName={rowHeightClassName} />}
134
153
  </div>
135
154
  );
136
155
  }
@@ -142,17 +161,37 @@ export function DataTableBody<T = unknown>({
142
161
  * sites use it, so a padded page and an empty one are the same height by
143
162
  * construction rather than by two matching guesses.
144
163
  */
145
- function PlaceholderRows({ count }: { count: number }) {
164
+ function PlaceholderRows({ count, rowHeightClassName }: { count: number; rowHeightClassName?: string }) {
146
165
  return (
147
166
  <>
148
167
  {Array.from({ length: count }).map((_, i) => (
149
168
  <div
150
169
  key={`placeholder-${i}`}
151
- className="pointer-events-none relative overflow-hidden rounded-md"
170
+ // `border-transparent`, not no border: a real row card draws a 1px
171
+ // border on each side, so its outer block is the inner height PLUS 2.
172
+ // A pad row without one is 2px shorter than the row it stands in for
173
+ // — invisible per row, 54px over a 27-row remainder, which is the
174
+ // same shift `minRows` exists to prevent.
175
+ className={cn(
176
+ 'pointer-events-none relative overflow-hidden rounded-md border border-transparent',
177
+ // The stated slot height belongs on the CARD, exactly as a real
178
+ // row carries it — a real row with a sub-row is taller than its
179
+ // cells, so reserving the cells' height here left every pad row
180
+ // short by the sub-row.
181
+ rowHeightClassName,
182
+ )}
152
183
  aria-hidden="true"
153
184
  >
154
- <div className={cn('hidden py-0 md:flex', ROW_SHELL_CLASSES, ROW_HEIGHT_DESKTOP)} />
155
- <div className={cn('flex justify-start py-0 md:hidden', ROW_SHELL_CLASSES, ROW_HEIGHT_MOBILE)} />
185
+ <div
186
+ className={cn('hidden py-0 md:flex', ROW_SHELL_CLASSES, rowHeightClassName ? 'h-full' : ROW_HEIGHT_DESKTOP)}
187
+ />
188
+ <div
189
+ className={cn(
190
+ 'flex justify-start py-0 md:hidden',
191
+ ROW_SHELL_CLASSES,
192
+ rowHeightClassName ? 'h-full' : ROW_HEIGHT_MOBILE,
193
+ )}
194
+ />
156
195
  </div>
157
196
  ))}
158
197
  </>
@@ -20,6 +20,18 @@ export interface DataTableRowProps<T> {
20
20
  * clipped. Default keeps the fixed height.
21
21
  */
22
22
  autoHeight?: boolean;
23
+ /**
24
+ * REPLACES the height this row would otherwise take (`compact`, `autoHeight`
25
+ * or the design default). The one place a table states how tall a row is, so
26
+ * the pad rows and the skeleton can reserve the SAME number — see
27
+ * `DataTableBodyProps.rowHeightClassName`.
28
+ *
29
+ * It sizes the row SLOT — the whole card — not the cells inside it. A row
30
+ * with a `subRow` is taller than its cells, so sizing the cells would leave
31
+ * the pad rows (which have no sub-row) short by exactly the sub-row: on a
32
+ * phone board that was 96px per missing row.
33
+ */
34
+ rowHeightClassName?: string;
23
35
  className?: string;
24
36
  /** Expandable content rendered below the cells, inside the same card. */
25
37
  subRow?: ReactNode;
@@ -62,7 +74,16 @@ export interface DataTableRowProps<T> {
62
74
  * for `onClick` and `className` (if function) via `useCallback` / `useMemo` in
63
75
  * the consumer. `href` and string `className` are compared by value.
64
76
  */
65
- function DataTableRowImpl<T>({ row, onClick, href, compact, autoHeight, className, subRow }: DataTableRowProps<T>) {
77
+ function DataTableRowImpl<T>({
78
+ row,
79
+ onClick,
80
+ href,
81
+ compact,
82
+ autoHeight,
83
+ rowHeightClassName,
84
+ className,
85
+ subRow,
86
+ }: DataTableRowProps<T>) {
66
87
  const hasSubRow = subRow != null && subRow !== false;
67
88
  // A sub-row carries its own interactive controls, so it must not live inside the
68
89
  // row-level <Link>; when present, the link wraps only the cells.
@@ -90,7 +111,11 @@ function DataTableRowImpl<T>({ row, onClick, href, compact, autoHeight, classNam
90
111
  );
91
112
 
92
113
  const containerClassName = cn(
93
- 'block overflow-hidden rounded-md border border-ods-border bg-ods-card text-inherit no-underline',
114
+ 'overflow-hidden rounded-md border border-ods-border bg-ods-card text-inherit no-underline',
115
+ // A stated slot height goes HERE, on the card, with the cells flexing to
116
+ // fill whatever the sub-row leaves. `border-box` sizing means the number a
117
+ // caller writes is the number the row occupies, borders included.
118
+ rowHeightClassName ? `flex flex-col ${rowHeightClassName}` : 'block',
94
119
  // With a sub-row the link wraps only the cells, so keep the clickable affordance off the whole card.
95
120
  (onClick || isWholeCardLink) && 'cursor-pointer transition-colors hover:bg-ods-bg-active',
96
121
  className,
@@ -101,11 +126,13 @@ function DataTableRowImpl<T>({ row, onClick, href, compact, autoHeight, classNam
101
126
  className={cn(
102
127
  'flex',
103
128
  ROW_SHELL_CLASSES,
104
- compact
105
- ? 'py-[var(--spacing-system-xsf)]'
106
- : autoHeight
107
- ? 'min-h-[66px] py-[var(--spacing-system-sf)] md:min-h-[78px]'
108
- : `py-0 ${ROW_HEIGHT_DESKTOP}`,
129
+ rowHeightClassName
130
+ ? 'min-h-0 flex-1 py-0'
131
+ : compact
132
+ ? 'py-[var(--spacing-system-xsf)]'
133
+ : autoHeight
134
+ ? 'min-h-[66px] py-[var(--spacing-system-sf)] md:min-h-[78px]'
135
+ : `py-0 ${ROW_HEIGHT_DESKTOP}`,
109
136
  hasSubRow && 'border-b border-ods-border',
110
137
  )}
111
138
  >
@@ -153,7 +180,10 @@ function DataTableRowImpl<T>({ row, onClick, href, compact, autoHeight, classNam
153
180
  <Link
154
181
  href={href}
155
182
  prefetch={false}
156
- className="block cursor-pointer text-inherit no-underline transition-colors hover:bg-ods-bg-active"
183
+ className={cn(
184
+ 'cursor-pointer text-inherit no-underline transition-colors hover:bg-ods-bg-active',
185
+ rowHeightClassName ? 'flex min-h-0 flex-1 flex-col' : 'block',
186
+ )}
157
187
  onClick={handleClick}
158
188
  >
159
189
  {cells}
@@ -29,9 +29,17 @@ export interface DataTableSkeletonProps {
29
29
  rows?: number;
30
30
  className?: string;
31
31
  rowClassName?: string;
32
+ /**
33
+ * REPLACES the design row height, for a table whose rows are not that tall.
34
+ * Sizes the CARD, as a real row does.
35
+ * Appending a height through `rowClassName` cannot do this: tailwind-merge
36
+ * drops the plain `h-[66px]` but keeps `md:h-[78px]`, so the override held
37
+ * on a phone and lost on a desktop. See `DataTableBodyProps.rowHeightClassName`.
38
+ */
39
+ rowHeightClassName?: string;
32
40
  }
33
41
 
34
- export function DataTableSkeleton({ rows = 10, className, rowClassName }: DataTableSkeletonProps) {
42
+ export function DataTableSkeleton({ rows = 10, className, rowClassName, rowHeightClassName }: DataTableSkeletonProps) {
35
43
  const table = useDataTableContext();
36
44
  const columns = table.getVisibleFlatColumns();
37
45
  const firstColumnId = columns[0]?.id;
@@ -43,10 +51,19 @@ export function DataTableSkeleton({ rows = 10, className, rowClassName }: DataTa
43
51
  key={index}
44
52
  className={cn(
45
53
  'relative animate-pulse overflow-hidden rounded-md border border-ods-border bg-ods-card',
54
+ // The slot height sits on the card, matching a real row.
55
+ rowHeightClassName,
46
56
  className,
47
57
  )}
48
58
  >
49
- <div className={cn('hidden py-0 md:flex', ROW_SHELL_CLASSES, ROW_HEIGHT_DESKTOP, rowClassName)}>
59
+ <div
60
+ className={cn(
61
+ 'hidden py-0 md:flex',
62
+ ROW_SHELL_CLASSES,
63
+ rowHeightClassName ? 'h-full' : ROW_HEIGHT_DESKTOP,
64
+ rowClassName,
65
+ )}
66
+ >
50
67
  {columns.map(column => {
51
68
  const meta = column.columnDef.meta;
52
69
  return (
@@ -71,7 +88,14 @@ export function DataTableSkeleton({ rows = 10, className, rowClassName }: DataTa
71
88
  );
72
89
  })}
73
90
  </div>
74
- <div className={cn('flex justify-start py-0 md:hidden', ROW_SHELL_CLASSES, ROW_HEIGHT_MOBILE, rowClassName)}>
91
+ <div
92
+ className={cn(
93
+ 'flex justify-start py-0 md:hidden',
94
+ ROW_SHELL_CLASSES,
95
+ rowHeightClassName ? 'h-full' : ROW_HEIGHT_MOBILE,
96
+ rowClassName,
97
+ )}
98
+ >
75
99
  <div className="flex min-w-0 flex-1 flex-col justify-center py-[var(--spacing-system-sf)]">
76
100
  <div className="mb-[var(--spacing-system-xsf)] h-4 w-3/4 rounded-sm bg-ods-bg-surface" />
77
101
  <div className="h-3 w-1/2 rounded-sm bg-ods-bg-surface opacity-60" />