diff --git a/packages/react-components/components/stencil-generated/index.ts b/packages/react-components/components/stencil-generated/index.ts index e2655a52..9b4e2854 100644 --- a/packages/react-components/components/stencil-generated/index.ts +++ b/packages/react-components/components/stencil-generated/index.ts @@ -38,6 +38,7 @@ export const CbpList = /*@__PURE__*/createReactComponent('cbp-notice'); export const CbpPagination = /*@__PURE__*/createReactComponent('cbp-pagination'); export const CbpPanel = /*@__PURE__*/createReactComponent('cbp-panel'); +export const CbpRadio = /*@__PURE__*/createReactComponent('cbp-radio'); export const CbpSection = /*@__PURE__*/createReactComponent('cbp-section'); export const CbpSegmentedButtonGroup = /*@__PURE__*/createReactComponent('cbp-segmented-button-group'); export const CbpSkipNav = /*@__PURE__*/createReactComponent('cbp-skip-nav'); diff --git a/packages/web-components/assets/css/storybook-canvas.css b/packages/web-components/assets/css/storybook-canvas.css index a0f3a9b3..13bd1cfe 100644 --- a/packages/web-components/assets/css/storybook-canvas.css +++ b/packages/web-components/assets/css/storybook-canvas.css @@ -10,10 +10,6 @@ body:has(cbp-app[data-cbp-theme="dark"]) { } /* TechDebt: Remove when table component is added to design tokens story */ -#design-tokens h2 { - margin-block-end: 1rem; -} - #design-tokens table { width: 100%; border-collapse: collapse; diff --git a/packages/web-components/src/components/cbp-accordion/cbp-accordion.specs.mdx b/packages/web-components/src/components/cbp-accordion/cbp-accordion.specs.mdx index 69795341..5079fe9d 100644 --- a/packages/web-components/src/components/cbp-accordion/cbp-accordion.specs.mdx +++ b/packages/web-components/src/components/cbp-accordion/cbp-accordion.specs.mdx @@ -40,4 +40,5 @@ An Accordion is a common paradigm for progressive disclosure, organizing content * If manually setting multiple Accordion Items to open via the property, the component will not force only one open even if `multiple` is not specified. * So, you could specify all items open by default, regardless of this property. - * If `multiple` is not set to true, all Accordion Items will be closed when one is toggled to `open` via user interaction. \ No newline at end of file + * If `multiple` is not set to true, all Accordion Items will be closed when one is toggled to `open` via user interaction. +* TODO: Investigate implementing `hidden="until-found"` for collapsed content. (https://developer.chrome.com/docs/css-ui/hidden-until-found) \ No newline at end of file diff --git a/packages/web-components/src/components/cbp-app/cbp-app.scss b/packages/web-components/src/components/cbp-app/cbp-app.scss index 08cf7c85..836cd119 100644 --- a/packages/web-components/src/components/cbp-app/cbp-app.scss +++ b/packages/web-components/src/components/cbp-app/cbp-app.scss @@ -1,8 +1,6 @@ -/* Temporary workaround for verified Stencil bug; can use @use due to compilation error */ -//@import 'reset', 'roboto', 'css-variables', 'core'; - cbp-app { display: block; + position: relative; width: 100%; min-height: 100vh; color: var(--cbp-color-body-text); diff --git a/packages/web-components/src/components/cbp-app/cbp-app.specs.mdx b/packages/web-components/src/components/cbp-app/cbp-app.specs.mdx index 93b6bc04..78e9c3ae 100644 --- a/packages/web-components/src/components/cbp-app/cbp-app.specs.mdx +++ b/packages/web-components/src/components/cbp-app/cbp-app.specs.mdx @@ -6,18 +6,23 @@ import { Meta } from '@storybook/addon-docs'; ## Purpose -The App component is a wrapper that packages high level design tokens, styles, and fonts as well as acting as top level control for things like dark mode within the design system/component library. +The App component is a wrapper that packages high level design tokens (as CSS variables), styles, and fonts as well as acting as top level control for things like dark mode within the design system/component library. ## Functional Requirements -* The App component contains necessary high-level CSS and fonts to remove the need for HTML tags referencing external dependencies. -* As the highest level container of the application, system-wide functionality may be hoisted to this tag, such as dark mode, performance monitoring, debugging, etc. (TBD) +* The App component contains necessary high-level CSS and fonts to remove the need for external dependencies referenced by HTML `link` tags. +* As the highest level container of the application, system-wide functionality may be hoisted to this tag, such as dark mode, debugging, etc. (TBD) ## Technical Specifications ### User Interactions -n/a +* By setting the `theme` property, the application may be set to light or dark mode independently of the user's operating system settings. +* By setting the `debug` property to true, debug info will be logged to the console, including: + * Application name, if specified + * Application version, if specified + * Design System version + * StencilJS version ### Responsiveness diff --git a/packages/web-components/src/components/cbp-app/cbp-app.tsx b/packages/web-components/src/components/cbp-app/cbp-app.tsx index 15d1206f..e792c3ca 100644 --- a/packages/web-components/src/components/cbp-app/cbp-app.tsx +++ b/packages/web-components/src/components/cbp-app/cbp-app.tsx @@ -1,4 +1,4 @@ -import { Component, Prop, Host, h } from '@stencil/core'; +import { Component, Prop, Host, h, Env } from '@stencil/core'; /* An overarching "app" tag can act as a low-barrier way to get core design system elements (CSS, fonts) @@ -17,13 +17,18 @@ export class CbpApp { /** Optionally specifies light/dark mode. This is only needed if the application can change the theme separate from OS settings. */ @Prop({reflect: true}) theme: "light" | "dark" | "system" = "system" + @Prop({reflect: true}) debug: boolean; + + @Prop({reflect: true}) appName: string; + @Prop({reflect: true}) appVersion: string; + handleThemeChange(mql) { this.theme = mql.matches ? "dark" : "light"; } componentDidLoad() { const darkMode = window?.matchMedia(`(prefers-color-scheme: dark)`); - // Only set up the listener if we're using the system default, otherwise, it's being set manually + // Only set up the listener if we're using the system default, otherwise it's being set manually via reactive property if (this.theme == "system") { darkMode.addEventListener('change', mql => this.handleThemeChange(mql)); // Add an event listener to the media query this.handleThemeChange(darkMode); // Run the theme change handler once on load @@ -31,6 +36,16 @@ export class CbpApp { } render() { + // If debug is enabled, write debug info to the console + if (this.debug) { + let debugInfo = `DEBUGGING INFO:\n===============\n`; + if (this.appName) debugInfo += `Application name: ${this.appName}\n`; + if (this.appVersion) debugInfo += `Application version: ${this.appVersion}\n`; + debugInfo += `CBP Design System version: ${Env.version}\n`; + debugInfo += `Built with StencilJS: ${Env.stencil}`; + console.log(debugInfo); + } + return ( diff --git a/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.scss b/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.scss index dbf3aa67..38a3fe7a 100644 --- a/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.scss +++ b/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.scss @@ -104,6 +104,7 @@ cbp-checkbox { display: block; margin: var(--cbp-checkbox-margin); + position: relative; label { display: flex; @@ -129,7 +130,7 @@ cbp-checkbox { margin-inline-end: var(--cbp-space-2x); outline: 0; box-shadow: 0 0 0 calc(var(--cbp-space-5x) / 2) var(--cbp-checkbox-color-halo); - clip-path: circle(80%); + clip-path: circle(86%); // Check Mark/Dash &::before { @@ -177,8 +178,9 @@ cbp-checkbox { &::before { border-right: solid var(--cbp-border-size-lg) var(--cbp-checkbox-color); border-bottom: solid var(--cbp-border-size-lg) var(--cbp-checkbox-color); + //border-radius: 1px; height: 70%; - width: 30%; + width: 35%; transform: rotate(45deg) translateY(-10%) translateX(-10%); } } @@ -186,7 +188,8 @@ cbp-checkbox { &:indeterminate { // Indeterminate dash &::before { - border: solid var(--cbp-border-size-sm) var(--cbp-checkbox-color); + border: solid var(--cbp-border-size-md) var(--cbp-checkbox-color); + border-radius: var(--cbp-border-radius-soft); height: 0; width: 60%; } diff --git a/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.specs.mdx b/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.specs.mdx index b4b1d493..b0c806c1 100644 --- a/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.specs.mdx +++ b/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.specs.mdx @@ -13,6 +13,7 @@ The Checkbox component wraps the slotted native form control (`input type="check * The Checkbox component accepts the native form control (`input type="checkbox"`) and its label as slotted content, wrapping them in an implicit label. * The Checkbox component provides cross-browser styling for the form control in its various states, including hover, focus, disabled, and checked states. * The Checkbox component allows the form control to be set to an indeterminate state for "select all" and grouping functionality. +* A checkbox may be used individually as a standalone checkbox or within a checklist. ## Technical Specifications @@ -26,6 +27,7 @@ The Checkbox component wraps the slotted native form control (`input type="check ### Responsiveness * The checkbox label will wrap as needed. +* The checkbox control is sized in relative units and will respond to changes in the user's default text size. ### Accessibility diff --git a/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.tsx b/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.tsx index 2fae863b..b4f1d67e 100644 --- a/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.tsx +++ b/packages/web-components/src/components/cbp-checkbox/cbp-checkbox.tsx @@ -19,7 +19,7 @@ export class CbpCheckbox { /** The `name` attribute of the checkbox, which is passed as part of formData (as a key) only when the checkbox is checked. */ @Prop() name: string; - /** The `value` attribute of the checkbox, which is passed as part of formData (as a value) only when the checkbox is checked. */ + /** Optionally set the `value` attribute of the checkbox at the component level. Not needed if the slotted checkbox has a value. */ @Prop() value: string; /** Marks the checkbox as checked by default when specified. */ @@ -64,6 +64,7 @@ export class CbpCheckbox { @Watch('indeterminate') watchIndeterminateHandler(newValue: boolean) { if (this.formField) this.formField.indeterminate=newValue; + if (newValue == true) this.checked = false; } componentWillLoad() { @@ -85,9 +86,11 @@ export class CbpCheckbox { componentDidLoad() { // Set the disabled/indeterminate states on load only if true. (The Watch decorators only listen for changes, not initial state) if (!!this.formField) { - if (this.indeterminate) this.formField.indeterminate=this.indeterminate; if (this.checked) this.formField.checked=this.checked; + if (this.indeterminate && !this.checked) this.formField.indeterminate=this.indeterminate; // Checked takes precedence if (this.disabled) this.formField.setAttribute('disabled', ''); + if (this.name) this.formField.name=this.name; + if (this.value) this.formField.value=this.value; } } diff --git a/packages/web-components/src/components/cbp-form-field-wrapper/cbp-form-field-wrapper.scss b/packages/web-components/src/components/cbp-form-field-wrapper/cbp-form-field-wrapper.scss index 134fbbfc..61a8ccbc 100644 --- a/packages/web-components/src/components/cbp-form-field-wrapper/cbp-form-field-wrapper.scss +++ b/packages/web-components/src/components/cbp-form-field-wrapper/cbp-form-field-wrapper.scss @@ -23,7 +23,7 @@ cbp-form-field-wrapper { flex-basis: 100%; // for child flex context // Override the input padding based on overlay size to prevent input text from being obscured (text may still be obscured if there's not enough space for it) - input { + input:not(#fakeId) { padding-inline-start: calc(var(--cbp-form-field-overlay-start-width) + var(--cbp-form-field-wrapper-padding-start)); padding-inline-end: calc(var(--cbp-form-field-overlay-end-width) + var(--cbp-form-field-wrapper-padding-end)); } diff --git a/packages/web-components/src/components/cbp-form-field/cbp-form-field.scss b/packages/web-components/src/components/cbp-form-field/cbp-form-field.scss index 99c798e5..c87abaed 100644 --- a/packages/web-components/src/components/cbp-form-field/cbp-form-field.scss +++ b/packages/web-components/src/components/cbp-form-field/cbp-form-field.scss @@ -53,6 +53,11 @@ cbp-form-field { display: block; margin-bottom: var(--cbp-form-field-margin-bottom); + fieldset, + legend { + all: unset; + } + .cbp-form-field-label { display: block; color: var(--cbp-form-field-color-label); @@ -76,7 +81,8 @@ cbp-form-field { position: relative; } - input:not([type=checkbox]):not([type=radio]), + //input:not([type~="checkbox radio"]), + input:not([type="checkbox"]):not([type="radio"]), textarea, select, .cbp-custom-form-control { diff --git a/packages/web-components/src/components/cbp-form-field/cbp-form-field.specs.mdx b/packages/web-components/src/components/cbp-form-field/cbp-form-field.specs.mdx index cde32b79..5e9cb2d3 100644 --- a/packages/web-components/src/components/cbp-form-field/cbp-form-field.specs.mdx +++ b/packages/web-components/src/components/cbp-form-field/cbp-form-field.specs.mdx @@ -11,9 +11,14 @@ The Form Field component represents a generic, reusable pattern for form fields ## Functional Requirements * The Form Field component enforces the structure of an input pattern, containing: - * A label - * An optional description (or errors) - * The native form control (input, select, textarea, etc.) + * A `label` tag. + * An optional description (or errors). + * The native form control (input, select, textarea, etc.) slotted. +* For compound input patterns containing multiple form controls (e.g., checklist, radio list, etc.), this includes: + * A wrapping `fieldset` tag. + * A `legend` tag representing the pattern label. + * An optional description (or errors). + * The native form controls (input, select, textarea, etc.) slotted, with their own labels. * The Form Field component encapsulates styles for all types of HTML form fields that may be slotted within, including various states such as: * readonly * disabled @@ -36,6 +41,13 @@ The Form Field component represents a generic, reusable pattern for form fields * According to CBP Design System guidance, required fields should indicate "Required" in the field description in plain text. * The `required` attribute should not be used on the native form field because 1. screen readers would read "required" twice and 2. this attribute triggers browser-based validation, which will not behave consistently with application/custom validation. * Do not place `aria-required` on the native form field, as screen readers would read "required" twice. +* For single-input form fields: + * The label is rendered within a semantic `label` tag referencing the slotted form control by `id`. + * The (optional) description is associated to the form control as a description via the `aria-describedby` attribute. +* For multi-input form fields, such as radio lists or checklists: + * The entire patterns is wrapped within a `fieldset`, which provides an inherent "group" role. + * The component label is rendered as a `legend`, which provides the group's label. + * The (optional) description is associated to the `fieldset` via the `aria-describedby` attribute. * Disabled form controls and buttons are non-interactive and cannot be navigated to using the keyboard and should be used with caution (if at all). * Placeholder text should rarely, if ever, be used. * Especially in forms where the user is expected to enter data, placeholder text with sufficient contrast to the background color may be mistaken as entered input. @@ -69,4 +81,4 @@ The Form Field component represents a generic, reusable pattern for form fields * Use of the `size` attribute is discouraged as it does not represent a linear/consistent scale across input sizes and browsers. * Furthermore, `size` is not valid on some input types such as `type="number"`. * When necessary, it is recommended to use CSS to style the width of form fields (using a relative unit such as `ch` or `rem`) separate from their containers. -* TODO: Handling of groups of inputs can be done via role=group or a legend tag. The legend tag can accept a disabled attribute, which is an advantage. \ No newline at end of file +* TODO: needs additional testing of nested `cbp-form-field` components, making up compound input patterns (e.g., phone, address). \ No newline at end of file diff --git a/packages/web-components/src/components/cbp-form-field/cbp-form-field.stories.tsx b/packages/web-components/src/components/cbp-form-field/cbp-form-field.stories.tsx index 1e29914a..e3f78028 100644 --- a/packages/web-components/src/components/cbp-form-field/cbp-form-field.stories.tsx +++ b/packages/web-components/src/components/cbp-form-field/cbp-form-field.stories.tsx @@ -35,6 +35,81 @@ export default { }, }; + + +function generateCheckboxes(context, checkboxes) { + const html = checkboxes.map(({ label, name, value, checked, disabled }) => { + return ` + + + ${label} + `; + }); + return html.join(''); +} + +const ChecklistTemplate = ({ checkboxes, label, description, fieldId, group, error, context, sx }) => { + return ` + + ${generateCheckboxes(context, checkboxes)} + + `; +}; + +export const Checklist = ChecklistTemplate.bind({}); +Checklist.args = { + checkboxes: [ + { + label: "Checkbox 1", + name: "checkbox", + value: "1", + checked: false, + disabled: false + }, + { + label: "Checkbox 2", + name: "checkbox", + value: "2", + checked: false, + disabled: false + }, + { + label: "Checkbox 3", + name: "checkbox", + value: "3", + checked: false, + disabled: false + }, + { + label: "Checkbox 4", + name: "checkbox", + value: "4", + checked: false, + disabled: false + }, + ], + label: "Checklist Group Label", + group: true +} + + + const TextInputTemplate = ({ label, description, fieldId, error, readonly, disabled, value, context, sx }) => { return ` { + return ` + + + ${label} + `; + }); + return html.join(''); +} + +const RadioListTemplate = ({ radios, label, description, fieldId, group, error, context, sx }) => { + return ` + + ${generateRadios(context, radios)} + + `; +}; + +export const RadioList = RadioListTemplate.bind({}); +RadioList.args = { + radios: [ + { + label: "Radio button 1", + name: "radio", + value: "1", + checked: false, + disabled: false + }, + { + label: "Radio button 2", + name: "radio", + value: "2", + checked: false, + disabled: false + }, + { + label: "Radio button 3", + name: "radio", + value: "3", + checked: false, + disabled: false + }, + { + label: "Radio button 4", + name: "radio", + value: "4", + checked: false, + disabled: false + }, + ], + label: "Radio List Group Label", + group: true +} + + + const SelectTemplate = ({ label, description, fieldId, error, disabled, context, sx }) => { return ` { - if (this.disabled || this.readonly) el.disabled=true; - }); - } - // only attached buttons inherit the danger color when errors are present - if (!!this.attachedButtons) { - this.attachedButtons.forEach( (el) => { - if (this.error) el.color="danger"; - }); + if (!this.group) { + if (!!this.formField) { + if (this.readonly) this.formField.setAttribute('readonly', ''); + if (this.disabled) this.formField.setAttribute('disabled', ''); + if (this.error) this.formField.setAttribute('aria-invalid', 'true'); + } + if (this.formFieldComponent) { + if (this.readonly) this.formFieldComponent.readonly=true; + if (this.disabled) this.formFieldComponent.disabled=true; + if (this.error) this.formFieldComponent.error=true; + } + if (!!this.buttons) { + this.buttons.forEach( (el) => { + if (this.disabled || this.readonly) el.disabled=true; + }); + } + // only attached buttons inherit the danger color when errors are present + if (!!this.attachedButtons) { + this.attachedButtons.forEach( (el) => { + if (this.error) el.color="danger"; + }); + } } } render() { - return ( - - - -
- {this.error && } - {this.description} - -
- -
- -
- - -
- ); - } -} + // Grouped/compound form inputs + if (this.group) { + return ( + +
+ + {this.label} + + + +
+ {this.error && } + {this.description} + +
+ +
+ +
+ + +
+
+ ); + } + // Single input patterns + else { + return ( + + + + +
+ {this.error && } + {this.description} + +
+ +
+ +
+ + +
+ ); + } + } +} \ No newline at end of file diff --git a/packages/web-components/src/components/cbp-radio/cbp-radio.scss b/packages/web-components/src/components/cbp-radio/cbp-radio.scss new file mode 100644 index 00000000..04e6f460 --- /dev/null +++ b/packages/web-components/src/components/cbp-radio/cbp-radio.scss @@ -0,0 +1,232 @@ +/** + * @prop --cbp-radio-color-bg: var(--cbp-color-white); + * @prop --cbp-radio-color-border: var(--cbp-color-interactive-secondary-dark); + * @prop --cbp-radio-color-border-hover: var(--cbp-color-interactive-secondary-darker); + * @prop --cbp-radio-color-border-focus: var(--cbp-color-interactive-focus-dark); + * @prop --cbp-radio-color-halo: transparent; + * @prop --cbp-radio-color-halo-hover: var(--cbp-color-interactive-secondary-lighter); + * @prop --cbp-radio-color-halo-focus: var(--cbp-color-interactive-focus-light); + * @prop --cbp-radio-color-bg-checked: var(--cbp-color-interactive-selected-dark); + * @prop --cbp-radio-color-bg-checked-focus: var(--cbp-color-interactive-focus-dark); + * @prop --cbp-radio-color-border-checked: var(--cbp-color-interactive-selected-dark); + * @prop --cbp-radio-color-border-checked-hover: var(--cbp-color-interactive-selected-dark); + * @prop --cbp-radio-color-border-checked-focus: var(--cbp-color-white); + * @prop --cbp-radio-color-halo-checked-hover: var(--cbp-color-interactive-selected-light); + * @prop --cbp-radio-color-halo-checked-focus: var(--cbp-color-interactive-focus-light); + * @prop --cbp-radio-color-label: var(--cbp-color-text-darkest); + + * @prop --cbp-radio-color-bg-dark: var(--cbp-color-gray-cool-70); + * @prop --cbp-radio-color-border-dark: var(--cbp-color-interactive-secondary-light); + * @prop --cbp-radio-color-border-hover-dark: var(--cbp-color-interactive-secondary-lighter); + * @prop --cbp-radio-color-border-focus-dark: var(--cbp-color-interactive-focus-light); + * @prop --cbp-radio-color-halo-dark: transparent; + * @prop --cbp-radio-color-halo-hover-dark: var(--cbp-color-text-darker); + * @prop --cbp-radio-color-halo-focus-dark: var(--cbp-color-interactive-focus-dark); + * @prop --cbp-radio-color-bg-checked-dark: var(--cbp-color-interactive-selected-light); + * @prop --cbp-radio-color-bg-checked-focus-dark: var(--cbp-color-interactive-focus-light); + * @prop --cbp-radio-color-border-checked-dark: var(--cbp-color-interactive-selected-light); + * @prop --cbp-radio-color-border-checked-hover-dark: var(--cbp-color-interactive-selected-light); + * @prop --cbp-radio-color-border-checked-focus-dark: var(--cbp-color-black); + * @prop --cbp-radio-color-halo-checked-hover-dark: var(--cbp-color-interactive-selected-dark); + * @prop --cbp-radio-color-halo-checked-focus-dark: var(--cbp-color-interactive-focus-dark); + * @prop --cbp-radio-color-label-dark: var(--cbp-color-text-lightest); + + * @prop --cbp-radio-min-height: var(--cbp-space-11x); + * @prop --cbp-radio-margin: 0 0 var(--cbp-space-1x) 0; + * @prop --cbp-radio-font-weight-label: var(--cbp-font-weight-bold); + */ + :root { + //--cbp-radio-color: var(--cbp-color-text-lightest); + --cbp-radio-color-bg: var(--cbp-color-white); + --cbp-radio-color-border: var(--cbp-color-interactive-secondary-dark); + --cbp-radio-color-border-hover: var(--cbp-color-interactive-secondary-darker); + --cbp-radio-color-border-focus: var(--cbp-color-interactive-focus-dark); + --cbp-radio-color-halo: transparent; + --cbp-radio-color-halo-hover: var(--cbp-color-interactive-secondary-lighter); + --cbp-radio-color-halo-focus: var(--cbp-color-interactive-focus-light); + + --cbp-radio-color-checked: var(--cbp-color-interactive-selected-dark); + --cbp-radio-color-checked-focus: var(--cbp-color-interactive-focus-dark); + --cbp-radio-color-border-checked: var(--cbp-color-interactive-selected-dark); + --cbp-radio-color-border-checked-hover: var(--cbp-color-interactive-selected-dark); + --cbp-radio-color-border-checked-focus: var(--cbp-color-interactive-focus-dark); + --cbp-radio-color-halo-checked-hover: var(--cbp-color-interactive-selected-light); + --cbp-radio-color-halo-checked-focus: var(--cbp-color-interactive-focus-light); + --cbp-radio-color-label: var(--cbp-color-text-darkest); + + //--cbp-radio-color-dark: var(--cbp-color-text-darkest); + --cbp-radio-color-bg-dark: var(--cbp-color-gray-cool-70); + --cbp-radio-color-border-dark: var(--cbp-color-interactive-secondary-light); + --cbp-radio-color-border-hover-dark: var(--cbp-color-interactive-secondary-lighter); + --cbp-radio-color-border-focus-dark: var(--cbp-color-interactive-focus-light); + --cbp-radio-color-halo-dark: transparent; + --cbp-radio-color-halo-hover-dark: var(--cbp-color-interactive-secondary-dark); + --cbp-radio-color-halo-focus-dark: var(--cbp-color-interactive-focus-dark); + + --cbp-radio-color-checked-dark: var(--cbp-color-interactive-selected-light); + --cbp-radio-color-checked-focus-dark: var(--cbp-color-white); + --cbp-radio-color-border-checked-dark: var(--cbp-color-interactive-selected-light); + --cbp-radio-color-border-checked-hover-dark: var(--cbp-color-interactive-selected-light); + --cbp-radio-color-border-checked-focus-dark: var(--cbp-color-white); + --cbp-radio-color-halo-checked-hover-dark: var(--cbp-color-interactive-selected-dark); + --cbp-radio-color-halo-checked-focus-dark: var(--cbp-color-interactive-focus-dark); + --cbp-radio-color-label-dark: var(--cbp-color-text-lightest); + + --cbp-radio-min-height: var(--cbp-space-11x); + --cbp-radio-margin: 0 0 var(--cbp-space-1x) 0; + --cbp-radio-font-weight-label: var(--cbp-font-weight-bold); + +} + +// Displays dark design based on mode or context +[data-cbp-theme=light] cbp-radio[context*=dark], +[data-cbp-theme=dark] cbp-radio:not([context=dark-inverts]):not([context=light-always]) { + //--cbp-radio-color: var(--cbp-radio-color-dark); + --cbp-radio-color-bg: var(--cbp-radio-color-bg-dark); + --cbp-radio-color-border: var(--cbp-radio-color-border-dark); + --cbp-radio-color-border-hover: var(--cbp-radio-color-border-hover-dark); + --cbp-radio-color-border-focus: var(--cbp-radio-color-border-focus-dark); + --cbp-radio-color-halo: var(--cbp-radio-color-halo-dark); + --cbp-radio-color-halo-hover: var(--cbp-radio-color-halo-hover-dark); + --cbp-radio-color-halo-focus: var(--cbp-radio-color-halo-focus-dark); + + --cbp-radio-color-checked: var(--cbp-radio-color-checked-dark); + --cbp-radio-color-checked-focus: var(--cbp-radio-color-checked-focus-dark); + --cbp-radio-color-bg-checked-focus: var(--cbp-radio-color-bg-checked-focus-dark); + --cbp-radio-color-border-checked: var(--cbp-radio-color-border-checked-dark); + --cbp-radio-color-border-checked-hover: var(--cbp-radio-color-border-checked-hover-dark); + --cbp-radio-color-border-checked-focus: var(--cbp-radio-color-border-checked-focus-dark); + --cbp-radio-color-halo-checked-hover: var(--cbp-radio-color-halo-checked-hover-dark); + --cbp-radio-color-halo-checked-focus: var(--cbp-radio-color-halo-checked-focus-dark); + + --cbp-radio-color-label: var(--cbp-radio-color-label-dark); +} + +cbp-radio { + display: block; + margin: var(--cbp-radio-margin); + position: relative; + + label { + display: flex; + align-items: center; + min-height: var(--cbp-radio-min-height); + font-weight: var(--cbp-radio-font-weight-label); + color: var(--cbp-radio-color-label); + } + + input[type=radio] { + position: relative; + flex-shrink: 0; + appearance: none; // radios do not accept styling per design specs + //color: var(--cbp-radio-color); + background-color: var(--cbp-radio-color-bg); + border-color: var(--cbp-radio-color-border); + border-style: solid; + border-width: var(--cbp-border-size-md); + border-radius: var(--cbp-border-radius-circle); + // TechDebt: Testing which one works: seeing different results on different computers/base font sizes + height: var(--cbp-space-7x); + width: var(--cbp-space-7x); + //height: calc(var(--cbp-space-7x) - 1px); + //width: calc(var(--cbp-space-7x) - 1px); + margin: 0; + margin-inline-end: var(--cbp-space-2x); + outline: 0; + box-shadow: 0 0 0 calc(var(--cbp-space-5x) / 2) var(--cbp-radio-color-halo); + clip-path: circle(80%); // verified + + // Check Mark/Dash + &::before { + content: ''; + position: absolute; + margin: auto; + left: 0; + right: 0; + top: 0; + bottom: 0; + overflow: hidden; + height: 0; + width: 0; + background-color: var(--cbp-radio-color-checked); + border-radius: var(--cbp-border-radius-circle); + } + + // Verified: only need to set the base variables with higher level tokens that are swapped for dark mode + &:hover { + --cbp-radio-color-border: var(--cbp-radio-color-border-hover); + --cbp-radio-color-halo: var(--cbp-radio-color-halo-hover); + } + + &:focus { + --cbp-radio-color-border: var(--cbp-radio-color-border-focus); + --cbp-radio-color-halo: var(--cbp-radio-color-halo-focus); + } + + + &:checked { + //--cbp-radio-color-bg: var(--cbp-radio-color-bg-checked); + --cbp-radio-color-border: var(--cbp-radio-color-border-checked); + + &:hover { + --cbp-radio-color-border: var(--cbp-radio-color-border-checked-hover); + --cbp-radio-color-halo: var(--cbp-radio-color-halo-checked-hover); + } + + &:focus { + --cbp-radio-color-checked: var(--cbp-radio-color-checked-focus); + --cbp-radio-color-border: var(--cbp-radio-color-border-checked-focus); + --cbp-radio-color-halo: var(--cbp-radio-color-halo-checked-focus); + } + } + + &:checked { + // Checked inner-circle + &::before { + height: var(--cbp-space-4x); + width: var(--cbp-space-4x); + //transition: all var(--cbp-motion-duration-shortest) ease-in; + } + } + + } + + + &[disabled], + &:has(*:disabled) { + cursor: not-allowed; + + label { + font-style: italic; + } + } + + // These overrides need to be set at the component host level to work properly in dark mode + &[disabled], + &:has(*:disabled) { + // No focus for disabled form controls, hover state still exists so it must be invisible since this is a non-interactive element + //--cbp-radio-color: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-bg: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-border: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-border-hover: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-bg-checked: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-checked: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-border-checked: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-border-checked-hover: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-halo-hover: transparent; + --cbp-radio-color-halo-checked-hover: transparent; + --cbp-radio-color-label: var(--cbp-color-interactive-disabled-dark); + + //--cbp-radio-color-dark: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-bg-dark: var(--cbp-color-interactive-disabled-dark); + --cbp-radio-color-border-dark: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-border-hover-dark: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-checked-dark: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-bg-checked-dark: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-border-checked-dark: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-border-checked-hover-dark: var(--cbp-color-interactive-disabled-light); + --cbp-radio-color-halo-hover-dark: transparent; + --cbp-radio-color-halo-checked-hover-dark: transparent; + --cbp-radio-color-label-dark: var(--cbp-color-interactive-disabled-light); + } +} \ No newline at end of file diff --git a/packages/web-components/src/components/cbp-radio/cbp-radio.specs.mdx b/packages/web-components/src/components/cbp-radio/cbp-radio.specs.mdx new file mode 100644 index 00000000..2bc11511 --- /dev/null +++ b/packages/web-components/src/components/cbp-radio/cbp-radio.specs.mdx @@ -0,0 +1,47 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# cbp-radio + +## Purpose + +The Radio component wraps the slotted native form control (`input type="radio"`) and label text, providing cross-browser styling. + +## Functional Requirements + +* The Radio component accepts the native form control (`input type="radio"`) and its label as slotted content, wrapping them in an implicit label. +* The Radio component provides cross-browser styling for the form control in its various states, including hover, focus, disabled, and checked states. +* Radio buttons should always exist in a radio list pattern containing multiple radio buttons (with the same `name` attribute) inside of a `cbp-form-field` component. + +## Technical Specifications + +### User Interactions + +* The user interactions are that of a native Radio (`input type="radio"`) element: + * Clicking anywhere on the form control or label text will place focus on the control and mark it as the selected item. + * When a radio button is selected, any other previously selected radio button with the same name will be deselected automatically. + * Radio buttons are keyboard accessible by tabbing into the group and then navigating the list using the up and down arrows. + * Using the arrows changes the selection to the current radio button. + * Pressing tab within the group will exit the group and place focus on the next focusable element in the page. + +### Responsiveness + +* The radio button label will wrap as needed. +* The radio button control is sized in relative units and will respond to changes in the user's default text size. + +### Accessibility + +* The native radio button (`input type="radio"`) element and label text are wrapped within a `label` tag, forming an implicit label association (no `id` needed). +* Full keyboard navigation is supported, as detailed under "User Interactions" above. + +### Additional Notes and Considerations + +* This component may manage the radio's disabled state, but does its best to get out of the way if the application wants to manage those states directly on the slotted elements. +* Radio buttons belonging to the same group/list should have the same `name` attribute. +* A radio's value is only included in the submitted form data if: + 1. A radio button is selected. + 2. If the selected radio button has a `name` attribute. + 3. If the selected radio button has a `value` defined. If no `value` is defined, then "on" will be passed as a value, which is not usually helpful in this context. +* Firefox (alone) persists the dynamic checked state of an `input` across page loads. Use the autocomplete attribute to control this feature. +* Native radio button elements may not be `readonly` - only `disabled`. \ No newline at end of file diff --git a/packages/web-components/src/components/cbp-radio/cbp-radio.stories.tsx b/packages/web-components/src/components/cbp-radio/cbp-radio.stories.tsx new file mode 100644 index 00000000..b652f39d --- /dev/null +++ b/packages/web-components/src/components/cbp-radio/cbp-radio.stories.tsx @@ -0,0 +1,63 @@ +export default { + title: 'Components/Radio', + tags: ['autodocs'], + argTypes: { + label: { + name: 'label (slotted)', + description: 'Label text (slotted) for the radio button.', + control: 'text', + }, + name: { + description: 'Specifies the `name` attribute of the slotted radio button.', + control: 'text', + }, + value: { + description: 'Specifies the `value` attribute of the slotted radio button.', + control: 'text', + }, + checked: { + description: 'Specifies the `checked` attribute of the slotted radio button, which represents its initial checked state only.', + control: 'boolean', + }, + disabled: { + description: 'Renders the radio button in a disabled state. A disabled form control does not pass a value on native submit.', + control: 'boolean', + }, + context : { + control: 'select', + options: [ "light-inverts", "light-always", "dark-inverts", "dark-always"] + }, + sx: { + description: 'Supports adding inline styles as an object of key-value pairs comprised of CSS properties and values. Values should reference design tokens when possible.', + control: 'object', + }, + }, +}; + +const Template = ({ label, name, value, checked, disabled, context, sx }) => { + return ` + + + ${label} + + `; +}; + +export const Radio = Template.bind({}); +Radio.args = { + label: "Radio button label", + name: "radio", + value: "1", +} + diff --git a/packages/web-components/src/components/cbp-radio/cbp-radio.tsx b/packages/web-components/src/components/cbp-radio/cbp-radio.tsx new file mode 100644 index 00000000..c6041dcf --- /dev/null +++ b/packages/web-components/src/components/cbp-radio/cbp-radio.tsx @@ -0,0 +1,96 @@ +import { Component, Element, Prop, Event, EventEmitter, Watch, Host, h } from '@stencil/core'; +import { setCSSProps} from '../../utils/utils'; + + +/** + * @slot - the checkbox control and label text goes in the default slot, both of which are placed inside of the `label` element. The label should not include excessively long descriptive text. + */ +@Component({ + tag: 'cbp-radio', + styleUrl: 'cbp-radio.scss' +}) +export class CbpRadio { + + private formField: HTMLInputElement; + + @Element() host: HTMLElement; + + /** The `name` attribute of the radio button, which is passed as part of formData (as a key) only when the radio button is checked. */ + @Prop() name: string; + + /** Optionally set the `value` attribute of the radio button at the component level. Not needed if the slotted radio button has a value. */ + @Prop() value: string; + + /** Marks the radio button as checked by default when specified. */ + @Prop() checked: boolean; + + /** Marks the radio button in a disabled state when specified. */ + @Prop() disabled: boolean; + + /** Specifies the context of the component as it applies to the visual design and whether it inverts when light/dark mode is toggled. Default behavior is "light-inverts" and does not have to be specified. */ + @Prop({ reflect: true }) context: 'light-inverts' | 'light-always' | 'dark-inverts' | 'dark-always'; + + /** Supports adding inline styles as an object */ + @Prop() sx: any = {}; + + + //this.formField.indeterminate=true; + + /** A custom event emitted when the click event occurs for either a rendered button or anchor/link. */ + @Event() stateChanged: EventEmitter; + handleChange() { + this.checked=this.formField.checked; + this.stateChanged.emit({ + host: this.host, + nativeElement: this.formField, + value: this.formField.value, + checked: this.formField.checked + }); + } + + @Watch('disabled') + watchDisabledHandler(newValue: boolean) { + if (this.formField) { + (newValue) + ? this.formField.setAttribute('disabled', '') + : this.formField.removeAttribute('disabled'); + } + } + + componentWillLoad() { + if (typeof this.sx == 'string') { + this.sx = JSON.parse(this.sx) || {}; + } + setCSSProps(this.host, { + ...this.sx, + }); + + // query the DOM for the slotted form field and wire it up for accessibility and attach an event listener to it + this.formField = this.host.querySelector('input[type=radio]'); + + if (this.formField) { + this.formField.addEventListener('change', () => this.handleChange()); + } + } + + componentDidLoad() { + // Set the disabled/indeterminate states on load only if true. (The Watch decorators only listen for changes, not initial state) + if (!!this.formField) { + if (this.checked) this.formField.checked=this.checked; + if (this.disabled) this.formField.setAttribute('disabled', ''); + if (this.name) this.formField.name=this.name; + if (this.value) this.formField.value=this.value; + } + } + + render() { + return ( + + + + ); + } + +} diff --git a/packages/web-components/src/stories/Design-tokens.stories.tsx b/packages/web-components/src/stories/Design-tokens.stories.tsx index 3965bc6e..0ba12e0a 100644 --- a/packages/web-components/src/stories/Design-tokens.stories.tsx +++ b/packages/web-components/src/stories/Design-tokens.stories.tsx @@ -162,7 +162,7 @@ const Template = () => { currentObj = []; pageContents += ` -

${AllTokenNames[index]}

+ ${AllTokenNames[index]} @@ -178,6 +178,23 @@ const Template = () => { return `
+ Design Tokens +

+ Design tokens are a platform-agnostic way to represent design decisions, such as those pertaining to colors, typography, font and heading sizes, etc. + These tokens represent a two-tier system. + The top tier consists of abstract colors and values from which to choose. + The second tier consists of tokens that reference the top-level tokens as their values, such as the "theme" layer. +

+

+ For the web components, these tokens are translated to CSS custom properties (aka CSS variables) and feed directly into the web components' CSS APIs. + By wrapping your application in the cbp-app web component, these tokens are also exposed for the entire application to leverage. +

+

+ Any web component properties that accept CSS units (as well as the sx property) should reference design tokens rather than "magic numbers." + Even when writing custom application CSS, design tokens should be used as values whenever possible. + This extra level of abstraction leads to more maintainable code, reduces design decisions that don't align with the design system, and results in fewer "one-offs." +

+ ${pageContents}
`; diff --git a/packages/web-components/stencil.config.ts b/packages/web-components/stencil.config.ts index 206f818a..650026ae 100644 --- a/packages/web-components/stencil.config.ts +++ b/packages/web-components/stencil.config.ts @@ -1,6 +1,9 @@ import { Config } from '@stencil/core'; import { sass } from '@stencil/sass'; import { reactOutputTarget } from '@stencil/react-output-target'; +import { version as pkgVersion } from './package.json'; +import { version as StencilVersion} from '@stencil/core/compiler'; + export const config: Config = { namespace: 'cbp-web-components', @@ -64,4 +67,8 @@ export const config: Config = { testing: { browserHeadless: "new", }, + env: { + version: pkgVersion, + stencil: StencilVersion + } };