Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5c892f9
preview resolved value
francisrupert Apr 14, 2026
7fa224e
resolved value for dt-text
francisrupert Apr 14, 2026
43cd427
preview resolved value
francisrupert Apr 14, 2026
374ae9a
add component-size token resolution
francisrupert Apr 14, 2026
81cff9f
preview resolved value
francisrupert Apr 14, 2026
f6c4cf7
coerce option values to strings for comparison
francisrupert Apr 14, 2026
cc009f9
manage exclusions
francisrupert Apr 14, 2026
04c300b
add tooltip for font-size/line-height resolved values
francisrupert Apr 14, 2026
cfe04b8
extract condition checking into helper function
francisrupert Apr 14, 2026
b5bd9d0
tests
francisrupert Apr 14, 2026
6b8aae7
improve measurement element positioning to prevent layout shifts
francisrupert Apr 14, 2026
aac57a4
filter out defaults key from variant options
francisrupert Apr 15, 2026
165e24f
add color swatches to selection control for color token values
francisrupert Apr 15, 2026
3b855ba
add color token resolution with support for multiple class patterns aโ€ฆ
francisrupert Apr 15, 2026
5be19fa
Merge branch 'next' into combinator-show-rendered-value
francisrupert Apr 15, 2026
8ef064e
add color token resolution with support for multiple class patterns aโ€ฆ
francisrupert Apr 15, 2026
076198c
adjust controls panel width and update null value label to en dash
francisrupert Apr 15, 2026
65320c8
improve BEM modifier pattern matching to support nested modifiers andโ€ฆ
francisrupert Apr 15, 2026
1c4e7e1
Merge branch 'next' into combinator-show-rendered-value
francisrupert Apr 16, 2026
d2c4439
update tone tokenCategory to use text-tone design token rather than dโ€ฆ
francisrupert Apr 16, 2026
53c3859
add mutual exclusivity rules for button muted kind and primary importโ€ฆ
francisrupert Apr 16, 2026
ba1dfc6
reorder prop priority list to place importance before kind and add shโ€ฆ
francisrupert Apr 16, 2026
d6d35ee
add mutual exclusivity rules
francisrupert Apr 16, 2026
59c5be2
update rich text editor notice to use GFM alert syntax
francisrupert Apr 16, 2026
2451a0f
use place-items shorthand and add component-content wrapper with dispโ€ฆ
francisrupert Apr 16, 2026
be5393b
variant config housekeeping
francisrupert Apr 16, 2026
467367c
skip showDivider from prop dependency detection
francisrupert Apr 16, 2026
b05e843
remove 625 from stack gap prop values documentation
francisrupert Apr 17, 2026
ec48cd7
add token cache clearing on theme change
francisrupert Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 21 additions & 23 deletions packages/combinator/src/components/combinator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -361,20 +361,29 @@ function updateVariant (e) {
nextTick(() => { _presetChanging = false; });
}

/**
* Merges variant override data into an info object.
*
* @param {object} info - The info object to merge into.
* @param {object} variantData - The variant data to merge.
*/
function mergeVariantData (info, variantData) {
if (!variantData) return;
Object.entries(variantData).forEach(([memberGroup, members]) => {
if (memberGroup === 'exclusions') return;
Object.entries(members).forEach(([memberName, member]) => {
const infoMember = info[memberGroup]?.find(m => m.name === memberName);
if (infoMember) Object.assign(infoMember, member);
});
});
}

const defaultInfo = computed(() => {
const info = cloneInfoMembers(
getComponentInfo(props.component, props.documentation),
);
const defaultVariant = props.variants?.default;
if (defaultVariant) {
Object.entries(defaultVariant).forEach(([memberGroup, members]) => {
if (memberGroup === 'exclusions') return;
Object.entries(members).forEach(([memberName, member]) => {
const infoMember = info[memberGroup]?.find(m => m.name === memberName);
if (infoMember) Object.assign(infoMember, member);
});
});
}
mergeVariantData(info, props.variants?.defaults);
Comment thread
francisrupert marked this conversation as resolved.
mergeVariantData(info, props.variants?.default);
return info;
});

Expand Down Expand Up @@ -406,19 +415,8 @@ function initializeInfo () {
getComponentInfo(props.component, props.documentation),
);

const variantInfo = props.variants?.[activeVariant.value];

if (variantInfo) {
Object.entries(variantInfo).forEach(([memberGroup, members]) => {
if (memberGroup === 'exclusions') return;
Object.entries(members).forEach(([memberName, member]) => {
const infoMember = info[memberGroup]?.find(m => m.name === memberName);
if (infoMember) {
Object.assign(infoMember, member);
}
});
});
}
mergeVariantData(info, props.variants?.defaults);
mergeVariantData(info, props.variants?.[activeVariant.value]);

info.exclusions = props.variants?.exclusions ?? [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<dt-segmented-control-item
v-for="option in options"
:key="option.value"
v-dt-tooltip="option.resolved ?? undefined"
:value="String(option.value)"
>
{{ option.label }}
Expand All @@ -27,6 +28,7 @@
import { DtSegmentedControl, DtSegmentedControlItem, DtText } from '@dialpad/dialtone-vue';

import { VALUE_UPDATE_EVENT } from '@/src/lib/constants';
import { resolveTokenValue } from '@/src/lib/tokens';
import { computed } from 'vue';

const props = defineProps({
Expand All @@ -46,6 +48,14 @@ const props = defineProps({
type: Function,
default: (value) => value.toString(),
},
tokenCategory: {
type: String,
default: undefined,
},
propValues: {
type: Object,
default: undefined,
},
});

const emit = defineEmits([VALUE_UPDATE_EVENT]);
Expand All @@ -54,6 +64,9 @@ const options = computed(() => {
return props.validValues?.map(v => ({
value: v,
label: props.generateLabel(v),
resolved: props.tokenCategory
? resolveTokenValue(props.tokenCategory, v, props.propValues)
: null,
})) ?? [];
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@
class="d-w100p"
label-class="d-jc-space-between d-fw-normal"
>
{{ selectedLabel }}<span aria-hidden="true">&thinsp;<!-- hold the space --></span>
{{ selectedLabel }}
<span aria-hidden="true">&thinsp;<!-- hold the space --></span>
<dt-text
v-if="selectedOption?.resolved"
v-dt-tooltip="selectedOption.resolved.includes('/') ? 'Font Size / Line Height' : undefined"
kind="body"
:size="100"
tone="muted"
>
{{ selectedOption.resolved }}
</dt-text>
<template #endIcon="{ iconSize }">
<dt-icon-chevrons-up-down
class="d-fc-muted"
Expand All @@ -37,9 +47,27 @@
:key="option.value"
role="menuitem"
navigation-type="arrow-keys"
@click="onInput(option.value); close()"
:class="{ 'd-o50 d-pe-none': option.disabled }"
:aria-disabled="option.disabled || undefined"
@click="!option.disabled && (onInput(option.value), close())"
Comment thread
braddialpad marked this conversation as resolved.
>
{{ option.label }}
<dt-stack
direction="row"
gap="200"
align="baseline"
class="d-w100p"
>
<span>{{ option.label }}</span>
<dt-text
v-if="option.resolved"
kind="body"
:size="100"
tone="muted"
class="d-mis-auto"
>
{{ option.resolved }}
</dt-text>
</dt-stack>
<template #end>
<dt-icon-check
size="200"
Expand All @@ -52,10 +80,11 @@
</template>

<script setup>
import { DtButton, DtText } from '@dialpad/dialtone-vue';
import { DtButton, DtStack, DtText } from '@dialpad/dialtone-vue';
import { DtIconChevronsUpDown, DtIconCheck } from '@dialpad/dialtone-icons/vue';

import { VALUE_UPDATE_EVENT } from '@/src/lib/constants';
import { resolveTokenValue } from '@/src/lib/tokens';
import { computed } from 'vue';

const props = defineProps({
Expand All @@ -79,6 +108,18 @@ const props = defineProps({
type: Function,
default: (value) => value.toString(),
},
tokenCategory: {
type: String,
default: undefined,
},
propValues: {
type: Object,
default: undefined,
},
disabledValues: {
type: Set,
default: undefined,
},
});

const emit = defineEmits([VALUE_UPDATE_EVENT]);
Expand All @@ -89,7 +130,11 @@ function onInput (e) {

const options = computed(() => {
const valueOptions = props.validValues?.map(selection => {
return { value: selection, label: props.generateLabel(selection) };
const optionDisabled = props.disabledValues?.has(String(selection)) ?? false;
const resolved = !optionDisabled && props.tokenCategory
? resolveTokenValue(props.tokenCategory, selection, props.propValues)
: null;
return { value: selection, label: props.generateLabel(selection), resolved, disabled: optionDisabled };
}) ?? [];

if (props.defaultValue === null || props.defaultValue === undefined) {
Expand All @@ -98,10 +143,11 @@ const options = computed(() => {
return valueOptions;
});

const selectedLabel = computed(() => {
const option = options.value.find(o => o.value === props.value);
return option?.label ?? '';
const selectedOption = computed(() => {
return options.value.find(o => String(o.value) === String(props.value));
});

const selectedLabel = computed(() => selectedOption.value?.label ?? '');
</script>

<script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
validTypes: member.types,
tags: member.tags,
bindings: member.bindings,
tokenCategory: member.tokenCategory,
propValues,
disabledValues: getDisabledValues(key, props.exclusionRules, props.propValues),
}"
@update:value="e => updateMember(e, key)"
@update:control="e => updateControl(e, key)"
Expand All @@ -38,14 +41,14 @@ import { computed, reactive } from 'vue';
import { convert } from '@/src/lib/convert';
import { controlMap } from '@/src/lib/control';
import { buildDependencyMap, shouldHideProp } from '@/src/lib/prop_dependencies';
import { shouldExclude } from '@/src/lib/exclusion_rules';
import { shouldExclude, getDisabledValues } from '@/src/lib/exclusion_rules';
import { isIconSlot } from '@/src/lib/icons';
import { DtStack } from '@dialpad/dialtone-vue';

const ICON_SLOT_ORDER = ['startIcon', 'endIcon', 'blockStartIcon', 'blockEndIcon', 'icon'];

const PROP_PRIORITY = [
'title', 'as', 'label', 'size', 'kind',
'title', 'as', 'label', 'kind', 'size',
'importance', 'placement', 'tone', 'align', 'density', 'strength',
'type', 'underline', 'selected', 'active', 'disabled', 'color', 'description',
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function renderTarget () {
*/
function renderError (exception, container) {
render(h(DtNotice, {
kind: 'error',
kind: 'critical',
hideClose: true,
title: ERROR_MESSAGE,
}, {
Expand Down
35 changes: 29 additions & 6 deletions packages/combinator/src/lib/exclusion_rules.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
function areConditionsMet (rule, propValues) {
return Object.entries(rule.when).every(([prop, condition]) =>
typeof condition === 'function'
? condition(propValues[prop])
: condition === propValues[prop],
);
}

/**
* Determines if a member should be excluded (hidden) based on exclusion rules
* and the current prop values.
Expand All @@ -11,12 +19,27 @@
export function shouldExclude (memberName, memberGroup, exclusionRules, propValues) {
if (!exclusionRules?.length) return false;
return exclusionRules.some(rule => {
const conditionsMet = Object.entries(rule.when).every(([prop, condition]) =>
typeof condition === 'function'
? condition(propValues[prop])
: condition === propValues[prop],
);
if (!conditionsMet) return false;
if (!areConditionsMet(rule, propValues)) return false;
return rule.hide?.[memberGroup]?.includes(memberName) ?? false;
});
}

/**
* Collects disabled values for a specific prop based on exclusion rules
* and the current prop values.
*
* @param {string} propName - The prop to check for disabled values.
* @param {Array} exclusionRules - Array of exclusion rule objects.
* @param {object} propValues - Current prop values.
* @returns {Set} Set of disabled value strings.
*/
export function getDisabledValues (propName, exclusionRules, propValues) {
const disabled = new Set();
if (!exclusionRules?.length) return disabled;
for (const rule of exclusionRules) {
if (!areConditionsMet(rule, propValues)) continue;
const values = rule.disableValues?.props?.[propName];
if (values) values.forEach(v => disabled.add(String(v)));
}
return disabled;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
75 changes: 75 additions & 0 deletions packages/combinator/src/lib/exclusion_rules.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { assert } from 'chai';
import { shouldExclude, getDisabledValues } from './exclusion_rules';

const EQUALITY_RULE = { when: { kind: 'body' }, disableValues: { props: { size: ['500'] } } };
const PREDICATE_RULE = { when: { kind: v => v !== 'headline' }, disableValues: { props: { size: ['500'] } } };
const HIDE_RULE = { when: { kind: 'count' }, hide: { props: ['decoration'] } };

describe('exclusion_rules', function () {
describe('getDisabledValues', function () {
it('should return empty Set when no rules', function () {
assert.equal(getDisabledValues('size', [], { kind: 'body' }).size, 0);
});

it('should return empty Set when rules is undefined', function () {
assert.equal(getDisabledValues('size', undefined, { kind: 'body' }).size, 0);
});

it('should contain value when equality condition matches', function () {
assert.isTrue(getDisabledValues('size', [EQUALITY_RULE], { kind: 'body' }).has('500'));
});

it('should return empty Set when equality condition does not match', function () {
assert.equal(getDisabledValues('size', [EQUALITY_RULE], { kind: 'headline' }).size, 0);
});

it('should contain value when predicate condition matches', function () {
assert.isTrue(getDisabledValues('size', [PREDICATE_RULE], { kind: 'body' }).has('500'));
});

it('should return empty Set when predicate condition does not match', function () {
assert.equal(getDisabledValues('size', [PREDICATE_RULE], { kind: 'headline' }).size, 0);
});

it('should union disabled values across multiple matching rules', function () {
const rules = [
{ when: { kind: 'body' }, disableValues: { props: { size: ['500'] } } },
{ when: { kind: 'body' }, disableValues: { props: { size: ['600'] } } },
];
assert.equal(getDisabledValues('size', rules, { kind: 'body' }).size, 2);
});

it('should coerce numeric values to strings', function () {
const rules = [
{ when: { kind: 'body' }, disableValues: { props: { size: [500] } } },
];
assert.isTrue(getDisabledValues('size', rules, { kind: 'body' }).has('500'));
});

it('should return empty Set for a prop not in disableValues', function () {
assert.equal(getDisabledValues('tone', [EQUALITY_RULE], { kind: 'body' }).size, 0);
});

it('should ignore rules that only have hide', function () {
assert.equal(getDisabledValues('decoration', [HIDE_RULE], { kind: 'count' }).size, 0);
});
});

describe('shouldExclude', function () {
it('should return false when no rules', function () {
assert.isFalse(shouldExclude('size', 'props', [], {}));
});

it('should return true when condition matches and member is in hide list', function () {
assert.isTrue(shouldExclude('decoration', 'props', [HIDE_RULE], { kind: 'count' }));
});

it('should return false when condition does not match', function () {
assert.isFalse(shouldExclude('decoration', 'props', [HIDE_RULE], { kind: 'label' }));
});

it('should return false when member is not in hide list', function () {
assert.isFalse(shouldExclude('size', 'props', [HIDE_RULE], { kind: 'count' }));
});
});
});
Loading
Loading