Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(documentation): Add pseudo-class to snapshots with postcss plugin #2092

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
214 changes: 214 additions & 0 deletions packages/documentation/postcss-pseudo-classes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* PostCSS plugin to automatically add in companion classes where pseudo-selectors are used.
* This allows you to add the class name to force the styling of a pseudo-selector, which can be really helpful for testing or being able to concretely reach all style states.
* @param {array} [options.blacklist] - Define elements to be ignored
* @param {array} [options.restrictTo] - Create classes for a restricted list of selectors. e.g. [':nth-child', 'hover']
* @param {boolean} [options.allCombinations=false] - When enabled output with all combinations of pseudo styles/pseudo classes.
* @param {boolean} [options.preserveBeforeAfter=true] - When enabled output does not generate pseudo classes for `:before` and `:after`.
* @param {string} [options.prefix='\\:'] - Define the pseudo-class class prefix. Default: ':' so the class name will be ':hover' for example.
* @author giuseppeg <https://github.com/giuseppeg>
* @author philippone <https://github.com/philippone>
* @author michaeldfoley <https://github.com/michaeldfoley>
* @see {@link https://github.com/giuseppeg/postcss-pseudo-classes}
* @returns {{postcssPlugin: string, Once(*): void}}
*/
Comment on lines +1 to +14
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, we should publish it as new package perhaps?

const plugin = (options = {}) => {
options.preserveBeforeAfter = options?.preserveBeforeAfter || true;

// Backwards compatibility--we always by default ignored `:root`.
const blacklist = {
':root': true,
':host': true,
':host-context': true,
':not': true,
':is': true,
':where': true,
':has': true,
':nth-child': true,
':nth-last-child': true,
':nth-last-of-type': true,
':first-child': true,
':last-child': true,
':first': true,
':last': true,
':required': true,
':scope': true,
':target': true,
':valid': true,
':user-valid': true,
':user-invalid': true,
':placeholder-shown': true,
};

const prefix = options?.prefix || '\\:';

(options?.blacklist || []).forEach(function (blacklistItem) {
blacklist[blacklistItem] = true;
});

let restrictTo;

if (Array.isArray(options.restrictTo) && options.restrictTo.length) {
restrictTo = options.restrictTo.reduce(function (target, pseudoClass) {
const finalClass =
(pseudoClass.charAt(0) === ':' ? '' : ':') + pseudoClass.replace(/\(.*/g, '');
if (!Object.hasOwn(target, finalClass)) {
target[finalClass] = true;
}
return target;
}, {});
}

return {
postcssPlugin: 'postcss-pseudo-classes',
Once(css) {
css.walkRules(function (rule) {
let combinations;

rule.selectors.forEach(function (selector) {
// Ignore some popular things that are never useful
if (blacklist[selector]) {
return;
}

const selectorParts = selector.split(' ');
const pseudoedSelectorParts = [];

selectorParts.forEach(function (selectorPart, index) {
const pseudos = selectorPart.match(/::?([^:]+)/g);

if (!pseudos) {
if (options.allCombinations) {
pseudoedSelectorParts[index] = [selectorPart];
} else {
pseudoedSelectorParts.push(selectorPart);
}
return;
}

const baseSelector = selectorPart.substr(
0,
selectorPart.length - pseudos.join('').length,
);

const classPseudos = pseudos.map(function (pseudo) {
const pseudoToCheck = pseudo.replace(/\(.*/g, '');
// restrictTo a subset of pseudo classes
if (
blacklist[pseudoToCheck] ||
pseudoToCheck.split('.').some(item => blacklist[item]) ||
pseudoToCheck.split('#').some(item => blacklist[item]) ||
(restrictTo && !restrictTo[pseudoToCheck])
) {
return pseudo;
}

// Ignore pseudo-elements!
if (pseudo.match(/^::/)) {
return pseudo;
}

// Ignore ':before' and ':after'
if (options.preserveBeforeAfter && [':before', ':after'].indexOf(pseudo) !== -1) {
return pseudo;
}

// Kill the colon
pseudo = pseudo.substr(1);

// check if pseudo is css function with opening and closing parentheses (.+)
if (pseudo.match(/\(.+\)/)) {
// Replace left and right parens
pseudo = pseudo.replace(/\(/g, '\\(');
Dismissed Show dismissed Hide dismissed
pseudo = pseudo.replace(/\)/g, '\\)');
Dismissed Show dismissed Hide dismissed
} else {
// Replace left and right parens
pseudo = pseudo.replace(/\(/g, '(');
Dismissed Show dismissed Hide dismissed
pseudo = pseudo.replace(/\)/g, ')');
Dismissed Show dismissed Hide dismissed
}

return '.' + prefix + pseudo;
});

// Add all combinations of pseudo selectors/pseudo styles given a
// selector with multiple pseudo styles.
if (options.allCombinations) {
combinations = createCombinations(pseudos, classPseudos);
pseudoedSelectorParts[index] = [];

combinations.forEach(function (combination) {
pseudoedSelectorParts[index].push(baseSelector + combination);
});
} else {
pseudoedSelectorParts.push(baseSelector + classPseudos.join(''));
}
});

if (options.allCombinations) {
const serialCombinations = createSerialCombinations(
pseudoedSelectorParts,
appendWithSpace,
);

serialCombinations.forEach(function (combination) {
addSelector(combination);
});
} else {
addSelector(pseudoedSelectorParts.join(' '));
}

function addSelector(newSelector) {
if (newSelector && newSelector !== selector) {
rule.selector += ',\n' + newSelector;
}
}
});
});
},
};
};

plugin.postcss = true;

module.exports = plugin;

// a.length === b.length
function createCombinations(a, b) {
let combinations = [''];
let newCombinations;
for (let i = 0, len = a.length; i < len; i += 1) {
newCombinations = [];
combinations.forEach(function (combination) {
newCombinations.push(combination + a[i]);
// Don't repeat work.
if (a[i] !== b[i]) {
newCombinations.push(combination + b[i]);
}
});
combinations = newCombinations;
}
return combinations;
}

// arr = [[list of 1st el], [list of 2nd el] ... etc]
function createSerialCombinations(arr, fn) {
let combinations = [''];
let newCombinations;
arr.forEach(function (elements) {
newCombinations = [];
elements.forEach(function (element) {
combinations.forEach(function (combination) {
newCombinations.push(fn(combination, element));
});
});
combinations = newCombinations;
});
return combinations;
}

function appendWithSpace(a, b) {
if (a) {
a += ' ';
}
return a + b;
}
7 changes: 7 additions & 0 deletions packages/documentation/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const postcssPseudoClasses = require('./postcss-pseudo-classes.js');

module.exports = () => {
return {
plugins: [postcssPseudoClasses],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't able to change the Vite config to use this plugin like vitejs/vite#8693

};
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const Badge: Story = {
interactionType: context.argTypes.interactionType.options,
nestedBadge: [false, true],
checked: [false, true],
pseudoClass: ['null', 'hover', 'focus-visible', ['focus-visible', 'hover']],
dismissed: [false],
})
.filter(args => !(args.interactionType !== 'checkable' && args.checked === true))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Args, Meta, StoryContext, StoryObj } from '@storybook/web-componen
import { html, nothing } from 'lit';
import { BADGE } from '../../../../.storybook/constants';
import { mapClasses } from '../../../utils';
import { serializeSimulatedPseudoClass } from '../../../utils/pseudo-class';

const meta: Meta = {
title: 'Components/Badge',
Expand Down Expand Up @@ -111,10 +112,10 @@ function externalControl(story: any, { args }: StoryContext) {
const button = html`
<a
href="#"
@click=${(e: Event) => {
@click="${(e: Event) => {
e.preventDefault();
updateArgs({ dismissed: false });
}}
}}"
>
Show badge
</a>
Expand All @@ -139,8 +140,10 @@ function getDefaultContent(args: Args) {

function getCheckableContent(args: Args, updateArgs: (args: Args) => void, context: StoryContext) {
const checkboxId = `badge-example--${context.name.replace(/ /g, '-').toLowerCase()}`;
const pseudoClassClass = serializeSimulatedPseudoClass(args.pseudoClass);
const labelClasses = mapClasses({
'badge-check-label': true,
[pseudoClassClass]: Boolean(pseudoClassClass) && pseudoClassClass !== 'null',
[args.size]: args.size !== 'default',
});

Expand All @@ -157,19 +160,19 @@ function getCheckableContent(args: Args, updateArgs: (args: Args) => void, conte

return html`
<input
id=${checkboxId}
id="${checkboxId}"
class="badge-check-input"
type="checkbox"
?checked=${args.checked}
@change=${handleChange}
?checked="${args.checked}"
@change="${handleChange}"
/>
<label class=${labelClasses} for=${checkboxId}>${getDefaultContent(args)}</label>
<label class="${labelClasses}" for="${checkboxId}">${getDefaultContent(args)}</label>
`;
}

function getDismissButton(updateArgs: (args: Args) => void) {
return html`
<button class="btn-close" @click=${() => updateArgs({ dismissed: true })}>
<button class="btn-close" @click="${() => updateArgs({ dismissed: true })}">
<span class="visually-hidden">Forigi insignon</span>
</button>
`;
Expand All @@ -186,14 +189,17 @@ function renderBadge(args: Args, context: StoryContext) {
const isCheckable = args.interactionType === 'checkable';
const isDismissible = args.interactionType === 'dismissible';

const pseudoClassClass = serializeSimulatedPseudoClass(args.pseudoClass);

const badgeClasses = mapClasses({
'badge': !isCheckable,
'badge-check': isCheckable,
[pseudoClassClass]: Boolean(pseudoClassClass) && pseudoClassClass !== 'null',
[args.size]: args.size !== 'default',
});

return html`
<div class=${badgeClasses}>
<div class="${badgeClasses}">
${isCheckable ? getCheckableContent(args, updateArgs, context) : getDefaultContent(args)}
${isDismissible ? getDismissButton(updateArgs) : nothing}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const ButtonGroup: Story = {
size: context.argTypes.size.options,
element: context.argTypes.element.options,
checked: context.argTypes.checked.options,
pseudoClass: ['null', 'hover', 'focus-visible'],
}).map((args: Args) => {
// Substitue checked with selected when element is checkbox
if (args.element === 'checkbox') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { Args, Meta, StoryContext, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import { html, nothing } from 'lit';
import { useArgs } from '@storybook/preview-api';
import { BADGE } from '../../../../.storybook/constants';
import { serializeSimulatedPseudoClass } from '../../../utils/pseudo-class';
import { appendClass } from '../../../utils';

const meta: Meta = {
title: 'Components/Button Group',
Expand Down Expand Up @@ -147,14 +149,15 @@ function createButtonTemplate(args: Args, context: StoryContext, index: number)
const id = `btngroup_${context.name}_${position}`;
const name = `btngroup_${context.name}`;
const label = args[`label_${position}`];
const pseudoClassClass = serializeSimulatedPseudoClass(args.pseudoClass);

switch (args.element) {
case 'checkbox': {
const isSelected = args.selected?.includes(position) ?? false;
return html`
<input
type="checkbox"
class="btn-check"
class="btn-check${isSelected ? appendClass(pseudoClassClass) : ''}"
id="${id}"
autocomplete="off"
?checked="${isSelected}"
Expand All @@ -181,7 +184,7 @@ function createButtonTemplate(args: Args, context: StoryContext, index: number)
return html`
<input
type="radio"
class="btn-check"
class="btn-check${isChecked ? appendClass(pseudoClassClass) : ''}"
name="${name}"
id="${id}"
autocomplete="off"
Expand All @@ -196,12 +199,26 @@ function createButtonTemplate(args: Args, context: StoryContext, index: number)
}
case 'link':
return html`
<a href="#" class="${`btn ${args.size} btn-secondary`}">${label}</a>
<a
href="#"
class="${`btn ${args.size} btn-secondary${
index === 0 ? appendClass(pseudoClassClass) : ''
}`}"
>
${label}
</a>
`;
case 'button':
default:
return html`
<button type="button" class="${`btn ${args.size} btn-secondary`}">${label}</button>
<button
type="button"
class="${`btn ${args.size} btn-secondary${
index === 0 ? appendClass(pseudoClassClass) : ''
}`}"
>
${label}
</button>
`;
}
}
Expand Down
Loading
Loading