@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.114 → 2.0.0-next.116

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 (33) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +29 -8
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +21 -4
  4. package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
  5. package/dist/building-blocks/readout-block/readout-block.js +5 -2
  6. package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
  7. package/dist/components/text-input-field/text-input-field.d.ts +1 -1
  8. package/dist/components/text-input-field/text-input-field.d.ts.map +1 -1
  9. package/dist/components/text-input-field/text-input-field.js +5 -2
  10. package/dist/components/text-input-field/text-input-field.js.map +1 -1
  11. package/dist/components/toggle-switch/toggle-switch.d.ts +16 -1
  12. package/dist/components/toggle-switch/toggle-switch.d.ts.map +1 -1
  13. package/dist/components/toggle-switch/toggle-switch.js +6 -1
  14. package/dist/components/toggle-switch/toggle-switch.js.map +1 -1
  15. package/dist/navigation-instruments/readout/readout-formatters.d.ts +23 -1
  16. package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
  17. package/dist/navigation-instruments/readout/readout-formatters.js +6 -4
  18. package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
  19. package/dist/navigation-instruments/readout/readout-shared.d.ts.map +1 -1
  20. package/dist/navigation-instruments/readout/readout-shared.js +9 -0
  21. package/dist/navigation-instruments/readout/readout-shared.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/building-blocks/readout-block/readout-block.stories.ts +243 -1
  24. package/src/building-blocks/readout-block/readout-block.ts +5 -1
  25. package/src/components/text-input-field/text-input-field.spec.ts +15 -0
  26. package/src/components/text-input-field/text-input-field.ts +6 -3
  27. package/src/components/toggle-switch/toggle-switch.spec.ts +31 -0
  28. package/src/components/toggle-switch/toggle-switch.ts +24 -2
  29. package/src/navigation-instruments/readout/readout-formatters.spec.ts +143 -3
  30. package/src/navigation-instruments/readout/readout-formatters.ts +43 -5
  31. package/src/navigation-instruments/readout/readout-shared.spec.ts +13 -0
  32. package/src/navigation-instruments/readout/readout-shared.ts +16 -0
  33. package/src/navigation-instruments/readout/readout.stories.ts +110 -0
@@ -1 +1 @@
1
- {"version":3,"file":"readout-formatters.js","sources":["../../../src/navigation-instruments/readout/readout-formatters.ts"],"sourcesContent":["export type ReadoutNumericFormatOptions = {\n showZeroPadding: boolean;\n minValueLength: number;\n fractionDigits: number;\n};\n\n/**\n * How a readout's `value` is interpreted.\n * - `number`: formatted via `fractionDigits` / `maxDigits`.\n * - `text`: rendered verbatim, with the numeric format options ignored.\n */\nexport enum ReadoutValueType {\n number = 'number',\n text = 'text',\n}\n\nconst READOUT_VALUE_TYPES: readonly string[] = Object.values(ReadoutValueType);\n\n/** Whether `value` is one of the supported {@link ReadoutValueType} values. */\nexport function isReadoutValueType(value: unknown): value is ReadoutValueType {\n return typeof value === 'string' && READOUT_VALUE_TYPES.includes(value);\n}\n\nfunction isBlank(value: string): boolean {\n return value.trim() === '';\n}\n\n/**\n * Throws when `valueType` is not a supported value, or when `value` is text but\n * `valueType` is `number`.\n *\n * `valueType` is validated first because an attribute carries an unchecked\n * string: a typo such as `valuetype=\"strng\"` matches neither mode, so every\n * mode check falls through and the readout silently renders the unavailable\n * dash — the opposite of the loud failure this contract exists to give.\n * `undefined`/`null` are allowed and mean \"use the default\".\n *\n * Attributes are always strings, so a numeric-looking string (`value=\"10.12\"`)\n * is accepted and parsed. Blank strings resolve to the unavailable dash rather\n * than throwing — `value=\"${maybeUndefined}\"` is a common template shape, and\n * `Number('')` is `0`, a silently wrong reading.\n */\nexport function assertReadoutValueType(\n tagName: string,\n value: number | string | null | undefined,\n valueType: ReadoutValueType\n): void {\n // `undefined`/`null` mean \"use the default\", matching how the components and\n // the resolvers treat an unset `valueType`.\n const resolved = valueType ?? ReadoutValueType.number;\n if (!isReadoutValueType(resolved)) {\n throw new TypeError(\n `<${tagName}>: valueType must be ` +\n `${READOUT_VALUE_TYPES.map((t) => `\"${t}\"`).join(' or ')} ` +\n `(got ${JSON.stringify(valueType)}).`\n );\n }\n if (\n resolved !== ReadoutValueType.number ||\n typeof value !== 'string' ||\n isBlank(value)\n ) {\n return;\n }\n if (Number.isFinite(Number(value))) {\n return;\n }\n throw new TypeError(\n `<${tagName}>: value must be a number when valueType is \"number\" ` +\n `(got ${JSON.stringify(value)}). Set valueType=\"text\" to render text.`\n );\n}\n\n/** The value as a number, or `undefined` when it is text / unavailable. */\nexport function resolveReadoutNumericValue(\n value: number | string | null | undefined,\n valueType: ReadoutValueType\n): number | undefined {\n if (\n valueType === ReadoutValueType.text ||\n value === null ||\n value === undefined\n ) {\n return undefined;\n }\n if (typeof value === 'number') {\n return value;\n }\n if (isBlank(value)) {\n return undefined;\n }\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : undefined;\n}\n\n/** The value as display text, or `undefined` when not in text mode / blank. */\nexport function resolveReadoutTextValue(\n value: number | string | null | undefined,\n valueType: ReadoutValueType\n): string | undefined {\n if (\n valueType !== ReadoutValueType.text ||\n value === null ||\n value === undefined\n ) {\n return undefined;\n }\n const text = typeof value === 'number' ? String(value) : value;\n return isBlank(text) ? undefined : text;\n}\n\nfunction dashedGenerator({\n showZeroPadding,\n minValueLength,\n fractionDigits,\n}: ReadoutNumericFormatOptions): string {\n const visibleDigits = showZeroPadding ? Math.max(minValueLength, 1) : 1;\n\n if (fractionDigits < 1) {\n return '-'.repeat(visibleDigits);\n }\n\n const integerDigits = visibleDigits - fractionDigits;\n\n return (\n '-'.repeat(Math.max(integerDigits, 1)) + '.' + '-'.repeat(fractionDigits)\n );\n}\n\nexport function formatNumericValue(\n value: number | undefined,\n options: ReadoutNumericFormatOptions\n): string {\n if (value === undefined) {\n return dashedGenerator(options);\n }\n\n return value.toFixed(options.fractionDigits);\n}\n\nexport function readoutFormattedInteger(valueText: string): number {\n const t = valueText.trim();\n if (!t) {\n return 0;\n }\n\n const rest = t.startsWith('-') ? t.slice(1) : t;\n const dot = rest.indexOf('.');\n return dot === -1 ? rest.length : dot;\n}\n\nexport function getHintZeros(\n value: number | undefined,\n {showZeroPadding, minValueLength, fractionDigits}: ReadoutNumericFormatOptions\n): string {\n const formattedValue = formatNumericValue(value, {\n showZeroPadding,\n minValueLength,\n fractionDigits,\n });\n const dotLength = fractionDigits > 0 ? 1 : 0;\n const integerLength = formattedValue.length - dotLength;\n const hintedDigits = Math.max(minValueLength - integerLength, 0);\n\n if (hintedDigits > 0) {\n return '0'.repeat(hintedDigits);\n }\n\n return '';\n}\n"],"names":["ReadoutValueType"],"mappings":"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"}
1
+ {"version":3,"file":"readout-formatters.js","sources":["../../../src/navigation-instruments/readout/readout-formatters.ts"],"sourcesContent":["export type ReadoutNumericFormatOptions = {\n showZeroPadding: boolean;\n minValueLength: number;\n fractionDigits: number;\n};\n\n/**\n * How a readout's `value` is interpreted.\n * - `number`: formatted via `fractionDigits` / `maxDigits`.\n * - `text`: rendered verbatim, with the numeric format options ignored.\n */\nexport enum ReadoutValueType {\n number = 'number',\n text = 'text',\n}\n\nconst READOUT_VALUE_TYPES: readonly string[] = Object.values(ReadoutValueType);\n\n/** Whether `value` is one of the supported {@link ReadoutValueType} values. */\nexport function isReadoutValueType(value: unknown): value is ReadoutValueType {\n return typeof value === 'string' && READOUT_VALUE_TYPES.includes(value);\n}\n\nfunction isBlank(value: string): boolean {\n return value.trim() === '';\n}\n\n/**\n * Throws when `valueType` is not a supported value, or when `value` is text but\n * `valueType` is `number`.\n *\n * `valueType` is validated first because an attribute carries an unchecked\n * string: a typo such as `valuetype=\"strng\"` matches neither mode, so every\n * mode check falls through and the readout silently renders the unavailable\n * dash — the opposite of the loud failure this contract exists to give.\n * `undefined`/`null` are allowed and mean \"use the default\".\n *\n * Attributes are always strings, so a numeric-looking string (`value=\"10.12\"`)\n * is accepted and parsed. Blank strings resolve to the unavailable dash rather\n * than throwing — `value=\"${maybeUndefined}\"` is a common template shape, and\n * `Number('')` is `0`, a silently wrong reading.\n */\nexport function assertReadoutValueType(\n tagName: string,\n value: number | string | null | undefined,\n valueType: ReadoutValueType\n): void {\n // `undefined`/`null` mean \"use the default\", matching how the components and\n // the resolvers treat an unset `valueType`.\n const resolved = valueType ?? ReadoutValueType.number;\n if (!isReadoutValueType(resolved)) {\n throw new TypeError(\n `<${tagName}>: valueType must be ` +\n `${READOUT_VALUE_TYPES.map((t) => `\"${t}\"`).join(' or ')} ` +\n `(got ${JSON.stringify(valueType)}).`\n );\n }\n if (\n resolved !== ReadoutValueType.number ||\n typeof value !== 'string' ||\n isBlank(value)\n ) {\n return;\n }\n if (Number.isFinite(Number(value))) {\n return;\n }\n throw new TypeError(\n `<${tagName}>: value must be a number when valueType is \"number\" ` +\n `(got ${JSON.stringify(value)}). Set valueType=\"text\" to render text.`\n );\n}\n\n/**\n * The value as a number, or `undefined` when it is text / unavailable.\n *\n * A non-finite number (`NaN`, `±Infinity`) counts as unavailable and renders the\n * dash, the same as `null`. `NaN` is a runtime data condition — a sensor\n * dropout, a `0/0`, a bad parse — not a programmer error, so it must not throw;\n * and `value.toFixed()` would otherwise render the literal text `\"NaN\"` /\n * `\"Infinity\"` in place of a reading. This also makes the number path agree with\n * the string path below, which has always resolved a non-finite string to\n * `undefined` — before this, `<obc-readout value=\"NaN\">` rendered a dash while\n * `.value=${NaN}` rendered `\"NaN\"`.\n */\nexport function resolveReadoutNumericValue(\n value: number | string | null | undefined,\n valueType: ReadoutValueType\n): number | undefined {\n if (\n valueType === ReadoutValueType.text ||\n value === null ||\n value === undefined\n ) {\n return undefined;\n }\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : undefined;\n }\n if (isBlank(value)) {\n return undefined;\n }\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : undefined;\n}\n\n/** The value as display text, or `undefined` when not in text mode / blank. */\nexport function resolveReadoutTextValue(\n value: number | string | null | undefined,\n valueType: ReadoutValueType\n): string | undefined {\n if (\n valueType !== ReadoutValueType.text ||\n value === null ||\n value === undefined\n ) {\n return undefined;\n }\n const text = typeof value === 'number' ? String(value) : value;\n return isBlank(text) ? undefined : text;\n}\n\n/**\n * The character used for an unavailable (\"no reading\") value.\n *\n * U+2012 FIGURE DASH, not the ASCII hyphen-minus: it is defined to be the same\n * width as a digit, so the placeholder lines up with the reading it stands in\n * for. Measured in Noto Sans with tabular figures at `size=\"m\"` — digit 13.02px,\n * U+2012 13.02px, U+002D 7.02px, en dash 11.02px, em dash 22.02px. With a\n * hyphen, `-.--` sat 46% narrow per character and its decimal point missed the\n * reading's; with U+2012 the point and every fraction position align exactly.\n */\nexport const READOUT_UNAVAILABLE_DASH = '\\u2012';\n\n/**\n * The unavailable (\"no reading\") text: a single integer dash plus one dash per\n * fraction digit — `\\u2012` at `fractionDigits` 0, `\\u2012.\\u2012\\u2012` at 2.\n *\n * Deliberately NOT filled out to `maxDigits`: the placeholder stays short and\n * sits at the right edge of the reserved width, rather than spelling out every\n * reserved digit position. `maxDigits` still reserves the width, so nothing\n * shifts when a reading arrives.\n */\nfunction dashedGenerator({\n showZeroPadding,\n minValueLength,\n fractionDigits,\n}: ReadoutNumericFormatOptions): string {\n const visibleDigits = showZeroPadding ? Math.max(minValueLength, 1) : 1;\n\n if (fractionDigits < 1) {\n return READOUT_UNAVAILABLE_DASH.repeat(visibleDigits);\n }\n\n const integerDigits = visibleDigits - fractionDigits;\n\n return (\n READOUT_UNAVAILABLE_DASH.repeat(Math.max(integerDigits, 1)) +\n '.' +\n READOUT_UNAVAILABLE_DASH.repeat(fractionDigits)\n );\n}\n\nexport function formatNumericValue(\n value: number | undefined,\n options: ReadoutNumericFormatOptions\n): string {\n // Non-finite counts as unavailable here too, not only in\n // `resolveReadoutNumericValue`. Every caller normalises today, but this\n // function is exported, and `NaN.toFixed()` would put the literal text\n // \"NaN\" where a reading belongs — the exact failure this change removes.\n if (value === undefined || !Number.isFinite(value)) {\n return dashedGenerator(options);\n }\n\n return value.toFixed(options.fractionDigits);\n}\n\nexport function readoutFormattedInteger(valueText: string): number {\n const t = valueText.trim();\n if (!t) {\n return 0;\n }\n\n const rest = t.startsWith('-') ? t.slice(1) : t;\n const dot = rest.indexOf('.');\n return dot === -1 ? rest.length : dot;\n}\n\nexport function getHintZeros(\n value: number | undefined,\n {showZeroPadding, minValueLength, fractionDigits}: ReadoutNumericFormatOptions\n): string {\n const formattedValue = formatNumericValue(value, {\n showZeroPadding,\n minValueLength,\n fractionDigits,\n });\n const dotLength = fractionDigits > 0 ? 1 : 0;\n const integerLength = formattedValue.length - dotLength;\n const hintedDigits = Math.max(minValueLength - integerLength, 0);\n\n if (hintedDigits > 0) {\n return '0'.repeat(hintedDigits);\n }\n\n return '';\n}\n"],"names":["ReadoutValueType"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"readout-shared.d.ts","sourceRoot":"","sources":["../../../src/navigation-instruments/readout/readout-shared.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACrB,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,qBAAqB,EACtB,MAAM,sDAAsD,CAAC;AAC9D,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,2BAA2B,CAM7B;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,aAAa,EAAE,2BAA2B,GACzC,OAAO,CAQT;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,cAAc,CASzE;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,gBAAgB,GAAG,cAAc,CAS3E;AAED,4EAA4E;AAC5E,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,OAAO,GAClB,oBAAoB,CAItB;AAED,8EAA8E;AAC9E,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,uBAAuB,GAAG,SAAS,GAC/C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAKzB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,eAAe,EAAE,OAAO,EACxB,aAAa,EAAE,qBAAqB,GACnC,qBAAqB,CAEvB"}
1
+ {"version":3,"file":"readout-shared.d.ts","sourceRoot":"","sources":["../../../src/navigation-instruments/readout/readout-shared.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACrB,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,qBAAqB,EACtB,MAAM,sDAAsD,CAAC;AAC9D,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,2BAA2B,CAY7B;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,aAAa,EAAE,2BAA2B,GACzC,OAAO,CAkBT;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,cAAc,CASzE;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,gBAAgB,GAAG,cAAc,CAS3E;AAED,4EAA4E;AAC5E,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,OAAO,GAClB,oBAAoB,CAItB;AAED,8EAA8E;AAC9E,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,uBAAuB,GAAG,SAAS,GAC/C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAKzB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,eAAe,EAAE,OAAO,EACxB,aAAa,EAAE,qBAAqB,GACnC,qBAAqB,CAEvB"}
@@ -3,6 +3,12 @@ import { ReadoutBlockSize, ReadoutBlockDataQuality, ReadoutBlockHidePhase } from
3
3
  import { formatNumericValue } from "./readout-formatters.js";
4
4
  function readoutNumericFormatOptions(maxDigits, fractionDigits) {
5
5
  return {
6
+ // Comparison-only (see `isDisplayedAtSetpoint`), which returns early unless
7
+ // both operands are finite numbers — so `formatNumericValue` never reaches
8
+ // its unavailable-dash branch here and the padding would have no effect.
9
+ // `obc-readout-block` sets it to `false` for rendering as well, since the
10
+ // unavailable placeholder is deliberately short rather than filled out to
11
+ // `maxDigits`. Nothing enables it today.
6
12
  showZeroPadding: false,
7
13
  minValueLength: maxDigits,
8
14
  fractionDigits
@@ -12,6 +18,9 @@ function isDisplayedAtSetpoint(value, setpoint, formatOptions) {
12
18
  if (value === null || setpoint === void 0) {
13
19
  return false;
14
20
  }
21
+ if (!Number.isFinite(value) || !Number.isFinite(setpoint)) {
22
+ return false;
23
+ }
15
24
  return formatNumericValue(value, formatOptions) === formatNumericValue(setpoint, formatOptions);
16
25
  }
17
26
  function readoutPrimarySize(size) {
@@ -1 +1 @@
1
- {"version":3,"file":"readout-shared.js","sources":["../../../src/navigation-instruments/readout/readout-shared.ts"],"sourcesContent":["import {\n ObcTextboxSize,\n ObcTextboxFontWeight,\n} from '../../components/textbox/textbox.js';\nimport {\n ReadoutBlockSize,\n ReadoutBlockDataQuality,\n ReadoutBlockHidePhase,\n} from '../../building-blocks/readout-block/readout-block.js';\nimport {\n formatNumericValue,\n type ReadoutNumericFormatOptions,\n} from './readout-formatters.js';\n\n/**\n * Pure helpers shared by `obc-readout` and `obc-readout-list-item`. The two\n * components are layout siblings built from the same primitives + per-block\n * options API (see #1012); this module keeps their behavioral logic in\n * lock-step until an eventual merge.\n *\n * ## Features\n * - **Numeric formatting:** {@link readoutNumericFormatOptions} builds the\n * shared `maxDigits`/`fractionDigits` format options (never zero-padded).\n * - **Setpoint comparison:** {@link isDisplayedAtSetpoint} compares the\n * DISPLAYED (rounded) value and setpoint strings, not the raw numbers.\n * - **Typography mapping:** {@link readoutPrimarySize} /\n * {@link readoutSecondarySize} map the density tier to the primary /\n * de-emphasised `obc-textbox` size; {@link readoutSetpointWeight} picks the\n * setpoint font weight from its emphasis state.\n * - **Data quality:** {@link readoutDataQualityClasses} produces the classMap\n * fragment for the low-integrity / invalid chip.\n * - **Pop-up hide phase:** {@link resolveSetpointHidePhase} maps the\n * component's deferred-hide state onto the block-level\n * `ReadoutBlockHidePhase`.\n *\n * ## Usage Guidelines\n * These helpers are consumed by the components' private getters — behavioral\n * changes belong here (once), not re-inlined per component.\n *\n * @example\n * const format = readoutNumericFormatOptions(3, 1);\n * const atSetpoint = isDisplayedAtSetpoint(29.99, 30, format);\n */\n\nexport function readoutNumericFormatOptions(\n maxDigits: number,\n fractionDigits: number\n): ReadoutNumericFormatOptions {\n return {\n showZeroPadding: false,\n minValueLength: maxDigits,\n fractionDigits,\n };\n}\n\n/**\n * Whether the value reads as \"at the setpoint\". Compares what is DISPLAYED\n * (rounded to fractionDigits), not the raw values, so e.g. 29.999 and 30 at\n * fractionDigits=0 both read \"30\" → at setpoint.\n */\nexport function isDisplayedAtSetpoint(\n value: number | null,\n setpoint: number | undefined,\n formatOptions: ReadoutNumericFormatOptions\n): boolean {\n if (value === null || setpoint === undefined) {\n return false;\n }\n return (\n formatNumericValue(value, formatOptions) ===\n formatNumericValue(setpoint, formatOptions)\n );\n}\n\n/** Primary value-typography size for a density tier. */\nexport function readoutPrimarySize(size: ReadoutBlockSize): ObcTextboxSize {\n switch (size) {\n case ReadoutBlockSize.large:\n return ObcTextboxSize.l;\n case ReadoutBlockSize.medium:\n return ObcTextboxSize.m;\n default:\n return ObcTextboxSize.s;\n }\n}\n\n/** Secondary (de-emphasised) value-typography size for a density tier. */\nexport function readoutSecondarySize(size: ReadoutBlockSize): ObcTextboxSize {\n switch (size) {\n case ReadoutBlockSize.large:\n return ObcTextboxSize.s;\n case ReadoutBlockSize.medium:\n return ObcTextboxSize.s;\n default:\n return ObcTextboxSize.xs;\n }\n}\n\n/** Setpoint is SemiBold only while emphasised, otherwise regular weight. */\nexport function readoutSetpointWeight(\n emphasized: boolean\n): ObcTextboxFontWeight {\n return emphasized\n ? ObcTextboxFontWeight.semibold\n : ObcTextboxFontWeight.regular;\n}\n\n/** classMap fragment for a block / source carrying per-block data quality. */\nexport function readoutDataQualityClasses(\n dataQuality: ReadoutBlockDataQuality | undefined\n): Record<string, boolean> {\n return {\n 'data-low-integrity': dataQuality === ReadoutBlockDataQuality.lowIntegrity,\n 'data-invalid': dataQuality === ReadoutBlockDataQuality.invalid,\n };\n}\n\n/**\n * The block-level hide phase for a pop-up setpoint: the component's deferred\n * phase while the value sits at the setpoint, `none` otherwise (value away\n * from the setpoint, or touching).\n */\nexport function resolveSetpointHidePhase(\n popUpAtSetpoint: boolean,\n deferredPhase: ReadoutBlockHidePhase\n): ReadoutBlockHidePhase {\n return popUpAtSetpoint ? deferredPhase : ReadoutBlockHidePhase.none;\n}\n"],"names":[],"mappings":";;;AA4CO,SAAS,4BACd,WACA,gBAC6B;AAC7B,SAAO;AAAA,IACL,iBAAiB;AAAA,IACjB,gBAAgB;AAAA,IAChB;AAAA,EAAA;AAEJ;AAOO,SAAS,sBACd,OACA,UACA,eACS;AACT,MAAI,UAAU,QAAQ,aAAa,QAAW;AAC5C,WAAO;AAAA,EACT;AACA,SACE,mBAAmB,OAAO,aAAa,MACvC,mBAAmB,UAAU,aAAa;AAE9C;AAGO,SAAS,mBAAmB,MAAwC;AACzE,UAAQ,MAAA;AAAA,IACN,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB;AACE,aAAO,eAAe;AAAA,EAAA;AAE5B;AAGO,SAAS,qBAAqB,MAAwC;AAC3E,UAAQ,MAAA;AAAA,IACN,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB;AACE,aAAO,eAAe;AAAA,EAAA;AAE5B;AAGO,SAAS,sBACd,YACsB;AACtB,SAAO,aACH,qBAAqB,WACrB,qBAAqB;AAC3B;AAGO,SAAS,0BACd,aACyB;AACzB,SAAO;AAAA,IACL,sBAAsB,gBAAgB,wBAAwB;AAAA,IAC9D,gBAAgB,gBAAgB,wBAAwB;AAAA,EAAA;AAE5D;AAOO,SAAS,yBACd,iBACA,eACuB;AACvB,SAAO,kBAAkB,gBAAgB,sBAAsB;AACjE;"}
1
+ {"version":3,"file":"readout-shared.js","sources":["../../../src/navigation-instruments/readout/readout-shared.ts"],"sourcesContent":["import {\n ObcTextboxSize,\n ObcTextboxFontWeight,\n} from '../../components/textbox/textbox.js';\nimport {\n ReadoutBlockSize,\n ReadoutBlockDataQuality,\n ReadoutBlockHidePhase,\n} from '../../building-blocks/readout-block/readout-block.js';\nimport {\n formatNumericValue,\n type ReadoutNumericFormatOptions,\n} from './readout-formatters.js';\n\n/**\n * Pure helpers shared by `obc-readout` and `obc-readout-list-item`. The two\n * components are layout siblings built from the same primitives + per-block\n * options API (see #1012); this module keeps their behavioral logic in\n * lock-step until an eventual merge.\n *\n * ## Features\n * - **Numeric formatting:** {@link readoutNumericFormatOptions} builds the\n * shared `maxDigits`/`fractionDigits` format options (never zero-padded).\n * - **Setpoint comparison:** {@link isDisplayedAtSetpoint} compares the\n * DISPLAYED (rounded) value and setpoint strings, not the raw numbers.\n * - **Typography mapping:** {@link readoutPrimarySize} /\n * {@link readoutSecondarySize} map the density tier to the primary /\n * de-emphasised `obc-textbox` size; {@link readoutSetpointWeight} picks the\n * setpoint font weight from its emphasis state.\n * - **Data quality:** {@link readoutDataQualityClasses} produces the classMap\n * fragment for the low-integrity / invalid chip.\n * - **Pop-up hide phase:** {@link resolveSetpointHidePhase} maps the\n * component's deferred-hide state onto the block-level\n * `ReadoutBlockHidePhase`.\n *\n * ## Usage Guidelines\n * These helpers are consumed by the components' private getters — behavioral\n * changes belong here (once), not re-inlined per component.\n *\n * @example\n * const format = readoutNumericFormatOptions(3, 1);\n * const atSetpoint = isDisplayedAtSetpoint(29.99, 30, format);\n */\n\nexport function readoutNumericFormatOptions(\n maxDigits: number,\n fractionDigits: number\n): ReadoutNumericFormatOptions {\n return {\n // Comparison-only (see `isDisplayedAtSetpoint`), which returns early unless\n // both operands are finite numbers — so `formatNumericValue` never reaches\n // its unavailable-dash branch here and the padding would have no effect.\n // `obc-readout-block` sets it to `false` for rendering as well, since the\n // unavailable placeholder is deliberately short rather than filled out to\n // `maxDigits`. Nothing enables it today.\n showZeroPadding: false,\n minValueLength: maxDigits,\n fractionDigits,\n };\n}\n\n/**\n * Whether the value reads as \"at the setpoint\". Compares what is DISPLAYED\n * (rounded to fractionDigits), not the raw values, so e.g. 29.999 and 30 at\n * fractionDigits=0 both read \"30\" → at setpoint.\n */\nexport function isDisplayedAtSetpoint(\n value: number | null,\n setpoint: number | undefined,\n formatOptions: ReadoutNumericFormatOptions\n): boolean {\n if (value === null || setpoint === undefined) {\n return false;\n }\n // An unavailable reading is never \"at\" the setpoint. Callers normalise\n // `value` (via `resolveReadoutNumericValue`) but pass `setpoint` raw, so a\n // non-finite setpoint would otherwise be formatted as the literal \"NaN\" /\n // \"Infinity\" and compared as a string. The comparison result happens to be\n // correct either way — a normalised `value` can never also format to \"NaN\" —\n // but guarding both keeps the two operands symmetric and the invariant below\n // honest.\n if (!Number.isFinite(value) || !Number.isFinite(setpoint)) {\n return false;\n }\n return (\n formatNumericValue(value, formatOptions) ===\n formatNumericValue(setpoint, formatOptions)\n );\n}\n\n/** Primary value-typography size for a density tier. */\nexport function readoutPrimarySize(size: ReadoutBlockSize): ObcTextboxSize {\n switch (size) {\n case ReadoutBlockSize.large:\n return ObcTextboxSize.l;\n case ReadoutBlockSize.medium:\n return ObcTextboxSize.m;\n default:\n return ObcTextboxSize.s;\n }\n}\n\n/** Secondary (de-emphasised) value-typography size for a density tier. */\nexport function readoutSecondarySize(size: ReadoutBlockSize): ObcTextboxSize {\n switch (size) {\n case ReadoutBlockSize.large:\n return ObcTextboxSize.s;\n case ReadoutBlockSize.medium:\n return ObcTextboxSize.s;\n default:\n return ObcTextboxSize.xs;\n }\n}\n\n/** Setpoint is SemiBold only while emphasised, otherwise regular weight. */\nexport function readoutSetpointWeight(\n emphasized: boolean\n): ObcTextboxFontWeight {\n return emphasized\n ? ObcTextboxFontWeight.semibold\n : ObcTextboxFontWeight.regular;\n}\n\n/** classMap fragment for a block / source carrying per-block data quality. */\nexport function readoutDataQualityClasses(\n dataQuality: ReadoutBlockDataQuality | undefined\n): Record<string, boolean> {\n return {\n 'data-low-integrity': dataQuality === ReadoutBlockDataQuality.lowIntegrity,\n 'data-invalid': dataQuality === ReadoutBlockDataQuality.invalid,\n };\n}\n\n/**\n * The block-level hide phase for a pop-up setpoint: the component's deferred\n * phase while the value sits at the setpoint, `none` otherwise (value away\n * from the setpoint, or touching).\n */\nexport function resolveSetpointHidePhase(\n popUpAtSetpoint: boolean,\n deferredPhase: ReadoutBlockHidePhase\n): ReadoutBlockHidePhase {\n return popUpAtSetpoint ? deferredPhase : ReadoutBlockHidePhase.none;\n}\n"],"names":[],"mappings":";;;AA4CO,SAAS,4BACd,WACA,gBAC6B;AAC7B,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOL,iBAAiB;AAAA,IACjB,gBAAgB;AAAA,IAChB;AAAA,EAAA;AAEJ;AAOO,SAAS,sBACd,OACA,UACA,eACS;AACT,MAAI,UAAU,QAAQ,aAAa,QAAW;AAC5C,WAAO;AAAA,EACT;AAQA,MAAI,CAAC,OAAO,SAAS,KAAK,KAAK,CAAC,OAAO,SAAS,QAAQ,GAAG;AACzD,WAAO;AAAA,EACT;AACA,SACE,mBAAmB,OAAO,aAAa,MACvC,mBAAmB,UAAU,aAAa;AAE9C;AAGO,SAAS,mBAAmB,MAAwC;AACzE,UAAQ,MAAA;AAAA,IACN,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB;AACE,aAAO,eAAe;AAAA,EAAA;AAE5B;AAGO,SAAS,qBAAqB,MAAwC;AAC3E,UAAQ,MAAA;AAAA,IACN,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB,KAAK,iBAAiB;AACpB,aAAO,eAAe;AAAA,IACxB;AACE,aAAO,eAAe;AAAA,EAAA;AAE5B;AAGO,SAAS,sBACd,YACsB;AACtB,SAAO,aACH,qBAAqB,WACrB,qBAAqB;AAC3B;AAGO,SAAS,0BACd,aACyB;AACzB,SAAO;AAAA,IACL,sBAAsB,gBAAgB,wBAAwB;AAAA,IAC9D,gBAAgB,gBAAgB,wBAAwB;AAAA,EAAA;AAE5D;AAOO,SAAS,yBACd,iBACA,eACuB;AACvB,SAAO,kBAAkB,gBAAgB,sBAAsB;AACjE;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oicl/openbridge-webcomponents-full-bundle",
3
- "version": "2.0.0-next.114",
3
+ "version": "2.0.0-next.116",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,7 @@ import {
10
10
  ReadoutValueType,
11
11
  } from './readout-block.js';
12
12
  import './readout-block.js';
13
+ import '../../components/textbox/textbox.js';
13
14
  import '../../icons/icon-placeholder.js';
14
15
  import {
15
16
  ObcAlertFrameMode,
@@ -41,11 +42,18 @@ type BlockArgs = {
41
42
  // A faithful single-block render. The block inherits its colour from the host
42
43
  // context (the list-item normally drives it), so standalone it shows the neutral
43
44
  // default tone; `enhanced` switches to the accent tone.
45
+ //
46
+ // `value` is passed through as given, deliberately NOT as `args.value ?? null`:
47
+ // one unavailable case IS `undefined`, and coercing here would quietly turn it
48
+ // into the `null` case. `undefined` sits outside the declared `value` type —
49
+ // consumers should pass `null` — but an unset property arrives as `undefined` in
50
+ // practice and has to read the same, so the cast is what the case is testing
51
+ // rather than a way around the type checker.
44
52
  function renderBlock(args: Partial<BlockArgs>) {
45
53
  return html`
46
54
  <obc-readout-block
47
55
  .variant=${args.variant ?? ReadoutBlockVariant.value}
48
- .value=${args.value ?? null}
56
+ .value=${args.value as number | string | null}
49
57
  .valueType=${args.valueType ?? ReadoutValueType.number}
50
58
  .size=${args.size ?? ReadoutBlockSize.small}
51
59
  .enhanced=${args.enhanced ?? false}
@@ -415,6 +423,240 @@ export const TestValidationSurvivesUnrelatedUpdate: Story = {
415
423
  },
416
424
  };
417
425
 
426
+ // The designer's specification, revised in review: the unavailable placeholder
427
+ // stays SHORT (`-.--`, not `---.--`) and sits at the right of the reserved width.
428
+ // format: 000.00 · readout: 12.30 · hinted: 012.30 · not available: -.--
429
+ // `format: 000.00` maps to maxDigits 3 + fractionDigits 2.
430
+ const DESIGNER_SPEC_CASES: {
431
+ label: string;
432
+ expected: string;
433
+ args: Partial<BlockArgs>;
434
+ }[] = [
435
+ {
436
+ label: 'readout',
437
+ expected: '12.30',
438
+ args: {value: 12.3, maxDigits: 3, fractionDigits: 2},
439
+ },
440
+ {
441
+ label: 'readout with hinted',
442
+ expected: '012.30',
443
+ args: {value: 12.3, maxDigits: 3, fractionDigits: 2, hintedZeros: true},
444
+ },
445
+ {
446
+ label: 'not available',
447
+ expected: '-.--',
448
+ args: {value: null, maxDigits: 3, fractionDigits: 2},
449
+ },
450
+ {
451
+ label: 'not available, hinted enabled',
452
+ expected: '-.--',
453
+ args: {value: null, maxDigits: 3, fractionDigits: 2, hintedZeros: true},
454
+ },
455
+ ];
456
+
457
+ // Every input that reads as "no reading". All four of the first rows are the
458
+ // same placeholder — that IS the point: whichever way a reading goes missing,
459
+ // the readout looks identical.
460
+ const UNAVAILABLE_CASES: {
461
+ label: string;
462
+ args: Partial<BlockArgs>;
463
+ }[] = [
464
+ {
465
+ label: 'NaN',
466
+ args: {value: Number.NaN, maxDigits: 3, fractionDigits: 2},
467
+ },
468
+ {
469
+ label: 'Infinity',
470
+ args: {value: Number.POSITIVE_INFINITY, maxDigits: 3, fractionDigits: 2},
471
+ },
472
+ {
473
+ label: 'null',
474
+ args: {value: null, maxDigits: 3, fractionDigits: 2},
475
+ },
476
+ {
477
+ label: 'undefined — an unset value, same as null',
478
+ args: {value: undefined, maxDigits: 3, fractionDigits: 2},
479
+ },
480
+ {
481
+ label: 'null · no fraction digits',
482
+ args: {value: null, maxDigits: 3},
483
+ },
484
+ ];
485
+
486
+ // Stacked in one column under identical settings so the decimal points and the
487
+ // fraction positions can be checked against each other by eye.
488
+ const ALIGNMENT_CASES: Partial<BlockArgs>[] = [
489
+ {value: 12.3, maxDigits: 3, fractionDigits: 2},
490
+ {value: 4.5, maxDigits: 3, fractionDigits: 2},
491
+ {value: null, maxDigits: 3, fractionDigits: 2},
492
+ {value: Number.NaN, maxDigits: 3, fractionDigits: 2},
493
+ ];
494
+
495
+ /**
496
+ * **Unavailable values — for design review.**
497
+ *
498
+ * 1. **The placeholder is short.** `-.--` for `format: 000.00`, not `---.--`:
499
+ * `maxDigits` already reserves the width, so the dash sits at the right edge
500
+ * of the reserve rather than spelling out every reserved digit position.
501
+ * 2. **The dash is digit-width.** It is U+2012 FIGURE DASH, not the ASCII
502
+ * hyphen-minus. Measured in Noto Sans with tabular figures, a digit is
503
+ * 13.02px and U+2012 is 13.02px, while U+002D is 7.02px — so with a hyphen
504
+ * the placeholder's decimal point missed the reading's. `tabular-nums` does
505
+ * not help here: it equalises figures with each other and leaves punctuation
506
+ * untouched (measured identical with the feature on and off).
507
+ * 3. **`NaN` and `±Infinity` count as unavailable.** They previously rendered as
508
+ * the literal text `NaN` / `Infinity` in place of a reading. They are a
509
+ * runtime data condition (sensor dropout, `0/0`, a bad parse) rather than a
510
+ * programmer error, so they resolve to the dash rather than throwing.
511
+ *
512
+ * Hinted zeros are suppressed for an unavailable value, so the two "not
513
+ * available" rows are identical and nothing reads `----Na.N`.
514
+ *
515
+ * **Open question — dash treatment.** U+2012 is exactly digit-width, which is
516
+ * what makes the columns line up, but two adjacent dashes then run together
517
+ * into one bar. The last section renders the alternatives at a readable size:
518
+ * (A) the figure dash as implemented, (B) an ASCII hyphen centred in a
519
+ * digit-width cell — separated and still aligned, at the cost of a lighter,
520
+ * sparser look — and (C) the previous plain hyphen, for reference. Delete that
521
+ * section once the choice is made.
522
+ */
523
+ export const UnavailableValues: Story = {
524
+ render: () => html`
525
+ <style>
526
+ .rb-unavail {
527
+ display: grid;
528
+ grid-template-columns: max-content max-content max-content;
529
+ gap: 8px 24px;
530
+ align-items: center;
531
+ }
532
+ .rb-unavail-2col {
533
+ grid-template-columns: max-content max-content;
534
+ }
535
+ .rb-unavail-head {
536
+ font: 10px/1.2 var(--global-typography-ui-label-font-family, sans-serif);
537
+ text-transform: uppercase;
538
+ letter-spacing: 0.06em;
539
+ color: var(--element-neutral-color, #777);
540
+ }
541
+ .rb-unavail-label {
542
+ font: 12px/1.4 var(--global-typography-ui-label-font-family, sans-serif);
543
+ color: var(--element-neutral-color, #777);
544
+ }
545
+ .rb-unavail-spec {
546
+ margin: 20px 0 8px;
547
+ font: 12px/1.2 var(--global-typography-ui-label-font-family, sans-serif);
548
+ text-transform: uppercase;
549
+ letter-spacing: 0.06em;
550
+ color: var(--element-neutral-color, #777);
551
+ }
552
+ .rb-unavail-spec-value {
553
+ font:
554
+ 13px/1.4 ui-monospace,
555
+ monospace;
556
+ color: var(--element-neutral-color, #777);
557
+ }
558
+ .rb-unavail-now {
559
+ outline: 1px dashed rgba(0, 0, 0, 0.12);
560
+ width: max-content;
561
+ }
562
+ .rb-dash-options {
563
+ display: flex;
564
+ flex-direction: column;
565
+ gap: 8px;
566
+ width: max-content;
567
+ }
568
+ .rb-cellspan {
569
+ display: inline-block;
570
+ width: 1ch;
571
+ text-align: center;
572
+ }
573
+ /* One column, so the decimal points can be compared down the stack. */
574
+ .rb-align {
575
+ display: flex;
576
+ flex-direction: column;
577
+ align-items: flex-start;
578
+ gap: 2px;
579
+ width: max-content;
580
+ outline: 1px dashed rgba(0, 0, 0, 0.12);
581
+ }
582
+ </style>
583
+ <div class="rb-unavail-spec">Designer specification — format 000.00</div>
584
+ <div class="rb-unavail">
585
+ <div class="rb-unavail-head">Case</div>
586
+ <div class="rb-unavail-head">Specified</div>
587
+ <div class="rb-unavail-head">Rendered</div>
588
+ ${DESIGNER_SPEC_CASES.map(
589
+ (c) => html`
590
+ <div class="rb-unavail-label">${c.label}</div>
591
+ <div class="rb-unavail-spec-value">${c.expected}</div>
592
+ <div class="rb-unavail-now">
593
+ ${renderBlock({size: ReadoutBlockSize.medium, ...c.args})}
594
+ </div>
595
+ `
596
+ )}
597
+ </div>
598
+
599
+ <div class="rb-unavail-spec">
600
+ Alignment — decimal points and fraction positions line up
601
+ </div>
602
+ <div class="rb-align">
603
+ ${ALIGNMENT_CASES.map((args) =>
604
+ renderBlock({size: ReadoutBlockSize.medium, ...args})
605
+ )}
606
+ </div>
607
+
608
+ <div class="rb-unavail-spec">
609
+ Dash treatment — open question for the designer
610
+ </div>
611
+ <div class="rb-dash-options">
612
+ <div>
613
+ <div class="rb-unavail-label">reading, for reference</div>
614
+ <obc-textbox size="l" .tabularNums=${true}>12.30</obc-textbox>
615
+ </div>
616
+ <div>
617
+ <div class="rb-unavail-label">
618
+ A — U+2012 figure dash (implemented): digit-width, but adjacent dashes
619
+ run together
620
+ </div>
621
+ <obc-textbox size="l" .tabularNums=${true}
622
+ >&#8210;.&#8210;&#8210;</obc-textbox
623
+ >
624
+ </div>
625
+ <div>
626
+ <div class="rb-unavail-label">
627
+ B — hyphen in digit-width cells: separated, but lighter and sparser
628
+ </div>
629
+ <obc-textbox size="l" .tabularNums=${true}>
630
+ <span class="rb-cellspan">-</span>.<span class="rb-cellspan">-</span
631
+ ><span class="rb-cellspan">-</span>
632
+ </obc-textbox>
633
+ </div>
634
+ <div>
635
+ <div class="rb-unavail-label">
636
+ C — ASCII hyphen (previous): narrower than a digit, does not align
637
+ </div>
638
+ <obc-textbox size="l" .tabularNums=${true}>-.--</obc-textbox>
639
+ </div>
640
+ </div>
641
+
642
+ <div class="rb-unavail-spec">
643
+ Unavailable values — every unreadable input
644
+ </div>
645
+ <div class="rb-unavail rb-unavail-2col">
646
+ <div class="rb-unavail-head">Value</div>
647
+ <div class="rb-unavail-head">Rendered</div>
648
+ ${UNAVAILABLE_CASES.map(
649
+ (c) => html`
650
+ <div class="rb-unavail-label">${c.label}</div>
651
+ <div class="rb-unavail-now">
652
+ ${renderBlock({size: ReadoutBlockSize.medium, ...c.args})}
653
+ </div>
654
+ `
655
+ )}
656
+ </div>
657
+ `,
658
+ };
659
+
418
660
  export const DataQuality: Story = {
419
661
  render: () =>
420
662
  renderShowcase([
@@ -17,6 +17,7 @@ import {
17
17
  assertReadoutValueType,
18
18
  resolveReadoutNumericValue,
19
19
  resolveReadoutTextValue,
20
+ READOUT_UNAVAILABLE_DASH,
20
21
  ReadoutValueType,
21
22
  type ReadoutNumericFormatOptions,
22
23
  } from '../../navigation-instruments/readout/readout-formatters.js';
@@ -215,6 +216,9 @@ export class ObcReadoutBlock extends LitElement {
215
216
 
216
217
  private get numericFormatOptions(): ReadoutNumericFormatOptions {
217
218
  return {
219
+ // The unavailable placeholder stays short (`\u2012.\u2012\u2012`) rather than
220
+ // spelling out every reserved digit position — `maxDigits` already
221
+ // reserves the width, so it simply sits at the right edge of it.
218
222
  showZeroPadding: false,
219
223
  minValueLength: this.maxDigits,
220
224
  fractionDigits: this.fractionDigits,
@@ -334,7 +338,7 @@ export class ObcReadoutBlock extends LitElement {
334
338
  const text = this.off
335
339
  ? this.offText
336
340
  : isTextMode
337
- ? (textValue ?? '-')
341
+ ? (textValue ?? READOUT_UNAVAILABLE_DASH)
338
342
  : formatNumericValue(valueForFormat, formatOptions);
339
343
  // Hinted zeros pad the INTEGER part up to `maxDigits`, independent of
340
344
  // `fractionDigits` (the decimal point and fraction digits never count toward
@@ -173,6 +173,21 @@ describe('obc-text-input-field', () => {
173
173
 
174
174
  expect(changeHandler).toHaveBeenCalled();
175
175
  });
176
+
177
+ it('carries the committed value in the change detail', async () => {
178
+ const changeHandler = vi.fn();
179
+ el.addEventListener('change', changeHandler);
180
+
181
+ input.value = 'new value';
182
+ input.dispatchEvent(new InputEvent('input', {bubbles: true}));
183
+ input.dispatchEvent(new Event('change', {bubbles: true}));
184
+ await el.updateComplete;
185
+
186
+ const event = changeHandler.mock.calls[0][0] as CustomEvent<{
187
+ value: string;
188
+ }>;
189
+ expect(event.detail).toEqual({value: 'new value'});
190
+ });
176
191
  });
177
192
 
178
193
  describe('password visibility', () => {
@@ -58,7 +58,7 @@ export enum ObcTextInputFieldPlacement {
58
58
  * @slot label-icon - Icon displayed before the label text (when `hasLabelIcon` is true)
59
59
  * @slot helper-icon - Icon displayed before helper or error text (when `hasHelperIcon` is true)
60
60
  * @fires input - Standard input event on value change
61
- * @fires change - Standard change event on value change
61
+ * @fires change - {CustomEvent<{value: string}>} Dispatched on value change, carrying the committed value
62
62
  * @fires clear - Fired when the clear button is clicked
63
63
  * @fires blur - Fired when the input field is blurred
64
64
  * @stable
@@ -198,8 +198,11 @@ export class ObcTextInputField extends LitElement {
198
198
  </div>`;
199
199
  }
200
200
 
201
- private fireChangeEvent() {
202
- this.dispatchEvent(new CustomEvent('change'));
201
+ private fireChangeEvent(e: Event) {
202
+ const target = e.target as HTMLInputElement;
203
+ this.dispatchEvent(
204
+ new CustomEvent('change', {detail: {value: target.value}})
205
+ );
203
206
  }
204
207
 
205
208
  private get shouldUpdateValue(): boolean {
@@ -53,6 +53,20 @@ describe('obc-toggle-switch', () => {
53
53
  expect(event.detail).toEqual({checked: true});
54
54
  });
55
55
 
56
+ it('dispatches change event with checked detail', async () => {
57
+ const handler = vi.fn();
58
+ el.addEventListener('change', handler);
59
+
60
+ input.click();
61
+ await el.updateComplete;
62
+
63
+ expect(handler).toHaveBeenCalledTimes(1);
64
+ const event = handler.mock.calls[0]?.[0] as CustomEvent<{
65
+ checked: boolean;
66
+ }>;
67
+ expect(event.detail).toEqual({checked: true});
68
+ });
69
+
56
70
  it('combination of click and js control', async () => {
57
71
  el.checked = true;
58
72
  await el.updateComplete;
@@ -116,6 +130,23 @@ describe('obc-toggle-switch', () => {
116
130
  }>;
117
131
  expect(event.detail).toEqual({checked: true});
118
132
  });
133
+
134
+ it('change event reports the user-selected state even when no input listener responds', async () => {
135
+ const handler = vi.fn();
136
+ el.addEventListener('change', handler);
137
+
138
+ input.click();
139
+ await el.updateComplete;
140
+
141
+ // The host state stays unchanged (controlled), but the change event
142
+ // must report the user's selection, consistent with the input event.
143
+ expect(el.checked).toBe(false);
144
+ expect(handler).toHaveBeenCalledTimes(1);
145
+ const event = handler.mock.calls[0]?.[0] as CustomEvent<{
146
+ checked: boolean;
147
+ }>;
148
+ expect(event.detail).toEqual({checked: true});
149
+ });
119
150
  });
120
151
 
121
152
  describe('disabled', () => {
@@ -9,6 +9,10 @@ export type ObcToggleSwitchInputEvent = CustomEvent<{
9
9
  checked: boolean;
10
10
  }>;
11
11
 
12
+ export type ObcToggleSwitchChangeEvent = CustomEvent<{
13
+ checked: boolean;
14
+ }>;
15
+
12
16
  /**
13
17
  * `<obc-toggle-switch>` – A toggle switch component for binary on/off selection (also known as a switch, toggle, or enable/disable control).
14
18
  *
@@ -56,6 +60,12 @@ export type ObcToggleSwitchInputEvent = CustomEvent<{
56
60
  *
57
61
  * ### Events
58
62
  * - `input` – Fired when the toggle state changes (checked/unchecked).
63
+ * - `change` – Fired when the toggle state changes by user interaction.
64
+ *
65
+ * Both events report the state the user selected. When `externalControl` is
66
+ * true the component does not update itself; the consumer decides whether to
67
+ * apply the reported state to `checked` and may reject the interaction by
68
+ * leaving `checked` unchanged.
59
69
  *
60
70
  * ---
61
71
  *
@@ -85,7 +95,7 @@ export type ObcToggleSwitchInputEvent = CustomEvent<{
85
95
  *
86
96
  * @slot icon - Leading icon slot (shown when `hasIcon` is true)
87
97
  * @fires input - {ObcToggleSwitchInputEvent} Dispatched when the value of the input changes
88
- * @fires change - Dispatched when the value of the input changes by user interaction
98
+ * @fires change - {ObcToggleSwitchChangeEvent} Dispatched when the value of the input changes by user interaction
89
99
  * @stable
90
100
  */
91
101
  @customElement('obc-toggle-switch')
@@ -136,6 +146,13 @@ export class ObcToggleSwitch extends LitElement {
136
146
  */
137
147
  @property({type: Boolean}) externalControl = false;
138
148
 
149
+ /**
150
+ * The state the user selected in the most recent interaction. In
151
+ * externalControl mode this can differ from `checked` until the consumer
152
+ * accepts the change, so the change event reports it instead of `checked`.
153
+ */
154
+ private _userSelectedChecked?: boolean;
155
+
139
156
  /**
140
157
  * Handles input events to change the toggle state.
141
158
  * Prevents changes if the toggle is disabled.
@@ -149,6 +166,7 @@ export class ObcToggleSwitch extends LitElement {
149
166
  }
150
167
 
151
168
  const nextChecked = !this.checked;
169
+ this._userSelectedChecked = nextChecked;
152
170
  if (!this.externalControl) {
153
171
  this.checked = nextChecked;
154
172
  }
@@ -169,7 +187,11 @@ export class ObcToggleSwitch extends LitElement {
169
187
  e.preventDefault();
170
188
  return;
171
189
  }
172
- this.dispatchEvent(new CustomEvent('change'));
190
+ this.dispatchEvent(
191
+ new CustomEvent('change', {
192
+ detail: {checked: this._userSelectedChecked ?? this.checked},
193
+ })
194
+ );
173
195
  }
174
196
 
175
197
  override render() {