diff --git a/docs/_includes/component.njk b/docs/_includes/component.njk index cbfd0f5c19..3ea2711da6 100644 --- a/docs/_includes/component.njk +++ b/docs/_includes/component.njk @@ -77,7 +77,7 @@ {# Guidelines #} {% if guidelines %} -

Usage guidelines

+

Usage Guidelines

{{ guidelines | markdown | safe }}
{% endif %} {# Importing #} diff --git a/docs/_includes/default.njk b/docs/_includes/default.njk index b42ca9a9ae..b0aaa4f9bb 100644 --- a/docs/_includes/default.njk +++ b/docs/_includes/default.njk @@ -166,7 +166,7 @@
diff --git a/docs/_includes/sidebar.njk b/docs/_includes/sidebar.njk index 0a99be2a85..8be1b3f1e0 100644 --- a/docs/_includes/sidebar.njk +++ b/docs/_includes/sidebar.njk @@ -2,38 +2,72 @@
  • Getting Started

  • Tutorials

  • Teamshares Brand

  • Styles

  • {# @@ -53,74 +87,191 @@
  • Core Components

  • Auxiliary Components

  • Utilities

  • - + \ No newline at end of file diff --git a/docs/assets/images/confirm-dialog-DO-2.png b/docs/assets/images/confirm-dialog-DO-2.png new file mode 100644 index 0000000000..d993c624d6 Binary files /dev/null and b/docs/assets/images/confirm-dialog-DO-2.png differ diff --git a/docs/assets/images/confirm-dialog-DO.png b/docs/assets/images/confirm-dialog-DO.png new file mode 100644 index 0000000000..529b89b7ba Binary files /dev/null and b/docs/assets/images/confirm-dialog-DO.png differ diff --git a/docs/assets/images/confirm-dialog-DONT-2.png b/docs/assets/images/confirm-dialog-DONT-2.png new file mode 100644 index 0000000000..7653df3cc9 Binary files /dev/null and b/docs/assets/images/confirm-dialog-DONT-2.png differ diff --git a/docs/assets/images/confirm-dialog-DONT.png b/docs/assets/images/confirm-dialog-DONT.png new file mode 100644 index 0000000000..6f76111175 Binary files /dev/null and b/docs/assets/images/confirm-dialog-DONT.png differ diff --git a/docs/pages/components/alert.md b/docs/pages/components/alert.md index c40fb12380..f12e3dadd1 100644 --- a/docs/pages/components/alert.md +++ b/docs/pages/components/alert.md @@ -19,7 +19,7 @@ guidelines: | ![Don't use another icon not in the examples](../../assets/images/alert-icon-example-DONT.png "Don't use another icon not in the examples") - Don't use a one-off icon with your alert variant - - If you have a strong use case for using an one-off icon, check with the design team + - If you have a strong use case for using a one-off icon, check with the design team ::: **Using Headers in Alerts** @@ -42,16 +42,16 @@ guidelines: | ```html:preview -
    This is super informative
    - This is a standard informational alert. +
    This is the alert header
    + This is the alert message. Keep it simple!
    ``` ```pug:slim sl-alert open="true" sl-icon slot="icon" library="fa" name="fas-circle-info" - div slot="header" This is super informative - | This is a standard informational alert. + div slot="header" This is the alert header + | This is the alert message. Keep it simple! ``` ```jsx:react @@ -77,55 +77,57 @@ Set the `variant` attribute to change the alert's variant. ```html:preview -
    This is super informative
    - You can tell by how pretty the alert is. +
    We’ve simplified your login experience
    + You can now log in to both apps with one set of credentials.

    -
    You can safely exit the app now
    - Your changes have been saved. +
    Request approved
    + This request was approved on April 25, 2024.

    -
    Your session has ended
    - Please login again to continue. +
    This view is currently hidden from shareholders
    + Go to Company settings to edit the visibility of this page.

    -
    Your account has been deleted
    - We are very sorry to see you go! +
    Your payment is past due
    + To avoid late fees, pay your minimum amount due today.
    ``` ```pug:slim sl-alert variant="primary" open="true" sl-icon slot="icon" library="fa" name="fas-circle-info" - div slot="header" This is super informative - | You can tell by how pretty the alert is. + div slot="header" We’ve simplified your login experience! + | You can now log in to both apps with one set of credentials. br sl-alert variant="success" open="true" sl-icon slot="icon" library="fa" name="fas-circle-check" - div slot="header" Your changes have been saved - | You can safely exit the app now. + div slot="header" Request approved + | This request was approved on April 25, 2024. br sl-alert variant="warning" open="true" sl-icon slot="icon" library="fa" name="fas-triangle-exclamation" - div slot="header" Your session has ended - | Please login again to continue. + div slot="header" This view is currently hidden from shareholders + | Go to + a class="ts-text-link" href="#" Company settings + | to edit the visibility of this page. br sl-alert variant="danger" open="true" sl-icon slot="icon" library="fa" name="fas-circle-exclamation" - div slot="header" Your account has been deleted - | We are very sorry to see you go! + div slot="header" Your payment is past due + | To avoid late fees, pay your minimum amount due today. ``` ```jsx:react @@ -330,38 +332,31 @@ You should always use the `closable` attribute so users can dismiss the notifica
    Primary Success - Warning Danger - - -
    This is super informative
    - You can tell by how pretty the alert is. -
    - - - -
    Your changes have been saved
    - You can safely exit the app now. -
    + + +
    We’ve simplified your login experience
    + You can now log in to both apps with one set of credentials. +
    - - -
    Your session has ended
    - Please login again to continue. -
    + + +
    Request submitted
    + Your request to issue 1,000 shares to Grace Hopper has been submitted. +
    - - -
    Your account has been deleted
    - We are very sorry to see you go! -
    + + +
    Your settings couldn’t be updated
    + Please contact support for help with this issue. +
    +``` + +```pug:slim +form.validation + sl-radio-group label="Select at least one option" required="true" + sl-radio value="1" Option 1 + sl-radio value="2" Option 2 + sl-radio value="3" Option 3 + br + sl-button type="submit" variant="primary" Submit + +javascript: + const form = document.querySelector(.validation); + + // Wait for controls to be defined before attaching form listeners + await Promise.all([ + customElements.whenDefined('sl-checkbox-group'), + ]).then(() => { + // Handle form submit + form.addEventListener(submit, event => { + event.preventDefault(); + alert(All fields are valid!); + }); + }); +``` + +```jsx:react +import SlButton from '@teamshares/shoelace/dist/react/button'; +import SlIcon from '@teamshares/shoelace/dist/react/icon'; +import SlRadio from '@teamshares/shoelace/dist/react/radio'; +import SlRadioGroup from '@teamshares/shoelace/dist/react/radio-group'; +const App = () => { + function handleSubmit(event) { + event.preventDefault(); + alert('All fields are valid!'); + } + + return ( +
    + + + Option 1 + + + Option 2 + + + Option 3 + + +
    + + Submit + +
    + ); +}; +``` + +### Custom Validity + +Use the `setCustomValidity()` method to set a custom validation message. This will prevent the form from submitting and make the browser display the error message you provide. To clear the error, call this function with an empty string. + +```html:preview +
    + + You can optionally choose me + I'm optional too + You must choose me + +
    + Submit +
    + + +``` + +```pug:slim +form.validation + sl-radio-group label="Select the third option" required="true" + sl-radio value="1" You can optionally choose me + sl-radio value="2" I'm optional too + sl-radio value="3" You must choose me + br + sl-button type="submit" variant="primary" Submit + +javascript: + const form = document.querySelector(.custom-validity); + const checkboxGroup = form.querySelector('sl-checkbox-group'); + const errorMessage = 'You must choose the last option'; + + // Set initial validity as soon as the element is defined + customElements.whenDefined('sl-checkbox').then(() => { + checkboxGroup.setCustomValidity(errorMessage); + }); + + // Update validity when a selection is made + form.addEventListener('sl-change', () => { + const isValid = checkboxGroup.value.some(value => value.includes('option-3: true')); + checkboxGroup.setCustomValidity(isValid ? '' : errorMessage); + }); + + // Wait for controls to be defined before attaching form listeners + await Promise.all([ + customElements.whenDefined('sl-checkbox-group'), + ]).then(() => { + // Handle form submit + form.addEventListener(submit, event => { + event.preventDefault(); + alert(All fields are valid!); + }); + }); +``` + +```jsx:react +import SlButton from '@teamshares/shoelace/dist/react/button'; +import SlIcon from '@teamshares/shoelace/dist/react/icon'; +import SlRadio from '@teamshares/shoelace/dist/react/radio'; +import SlRadioGroup from '@teamshares/shoelace/dist/react/radio-group'; +const App = () => { + function handleSubmit(event) { + event.preventDefault(); + alert('All fields are valid!'); + } + + return ( +
    + + + Option 1 + + + Option 2 + + + Option 3 + + +
    + + Submit + +
    + ); +}; +``` diff --git a/docs/pages/components/checkbox.md b/docs/pages/components/checkbox.md index b8f79d7f90..7193528a01 100644 --- a/docs/pages/components/checkbox.md +++ b/docs/pages/components/checkbox.md @@ -3,6 +3,11 @@ meta: title: Checkbox description: Checkboxes allow the user to toggle an option on or off. layout: component +unusedProperties: | + - Sizes `small`, `large` + - Boolean `indeterminate` +guidelines: | + - Refer to the [Checkbox Group component general guidelines](/components/checkbox-group/#usage-guidelines) --- ## Examples @@ -10,11 +15,11 @@ layout: component ### Basic Checkbox ```html:preview -Checkbox +Financial products access ``` ```pug:slim -sl-checkbox Checkbox +sl-checkbox Financial products access ``` ```jsx:react @@ -27,16 +32,135 @@ const App = () => Checkbox; This component works with standard `
    ` elements. Please refer to the section on [form controls](/getting-started/form-controls) to learn more about form submission and client-side validation. ::: +### Description + +Add descriptive help text to individual checkbox items with the `description` attribute. For descriptions that contain HTML, use the `description` slot instead. + +```html:preview +Financial products access +``` + +```pug:slim +sl-checkbox description="Grants access to cash account and charge card features" Financial products access +``` + +```jsx:react +import SlCheckbox from '@shoelace-style/shoelace/dist/react/checkbox'; + +const App = () => Label; +``` + +### Contained + +Add the `contained` attribute to draw a card-like container around a checkbox. Add to a [Checkbox Group](/components/checkbox-group) to draw a container around each checkbox in the group. This style is useful for giving more emphasis to a checkbox or list of checkboxes. + +```html:preview +Financial products access +
    +
    + + Initiate outbound transfers + Approve outbound transfers + Export transactions + +``` + +```pug:slim +sl-checkbox description="Grants access to cash account and charge card features" contained="true" Financial products access +br +br +sl-checkbox-group label="Financial products permissions" contained="true" + sl-checkbox description="Requires separate initiators and approvers" + | Initiate outbound transfers + sl-checkbox description="Requires separate initiators and approvers" + | Approve outbound transfers + sl-checkbox description="Applies to both cash account and charge card" + | Export transactions +``` + +```jsx:react +import { SlCheckbox } from '@teamshares/shoelace/dist/react'; +const App = () => ( + <> + + Checked + + + Disabled + + + Checked +
    A short description about this option
    +
    + +); +``` + +:::tip +When checkboxes are wrapped with [Checkbox Group](/components/checkbox-group), adding the `contained` attribute to the parent Checkbox Group or to _any_ checkbox in the group will create `contained` checkboxes for the entire group. +::: + +### Selected Content + +Use the `selected-content` slot to display additional content (such as an input field) inside a `contained` checkbox when it is checked. The slot is unstyled by default. Use `::part(selected-content)` to style the content as needed. + +```html:preview +Grant financial products access +
    +

    A mobile number is required to grant this user access to financial products. The number will be used for login verification.

    +
    +
    + +``` + +```pug:slim +sl-checkbox style="width:100%" contained="true" + | Grant financial products access + div slot="selected-content" + p A mobile number is required to grant this user access to financial products. The number will be used for login verification. + sl-input style="width: 280px;" label="Mobile number" type="tel" required="true" optional-icon="true" +css: + sl-checkbox::part(selected-content) { + font-size: 14px; + font-weight: normal; + color: #6D7176; + } +``` + +```jsx:react +import { SlCheckbox } from '@teamshares/shoelace/dist/react'; +const App = () => ( + <> + + Checked + + + Disabled + + + Checked +
    A short description about this option
    +
    + +); +``` + ### Checked Use the `checked` attribute to activate the checkbox. ```html:preview -Checked +Financial products access ``` ```pug:slim -sl-checkbox checked="true" Checked +sl-checkbox checked="true" Financial products access ``` ```jsx:react @@ -49,6 +173,10 @@ const App = () => Checked; Use the `indeterminate` attribute to make the checkbox indeterminate. +:::warning +The `indeterminate` option for a checkbox is currently not part of the Teamshares Design System, and there is no Figma component for this option. Please check with the design team before using this option. +::: + ```html:preview Indeterminate ``` @@ -81,7 +209,7 @@ import SlCheckbox from '@teamshares/shoelace/dist/react/checkbox'; const App = () => Disabled; ``` -### Sizes + ### Custom Validity diff --git a/docs/pages/components/dialog.md b/docs/pages/components/dialog.md index b68f059802..73b907f973 100644 --- a/docs/pages/components/dialog.md +++ b/docs/pages/components/dialog.md @@ -3,17 +3,58 @@ meta: title: Dialog description: 'Dialogs, also called "modals", appear above the page and require the user''s immediate attention.' layout: component +guidelines: | + **Dialog Headers** + + - Keep headers **short** and **succinct** + - Use **sentence case** + - **Don't** wrap headers to multiple lines + - To prevent wrapping, keep header text short. If the shortened text still wraps, try using a larger dialog width. + + **Confirmation Dialogs** + + - **Header** should restate the action you are asking people to confirm + - **Header** should end with a question mark + - **Header** and **body** text should **not** ask "Are you sure..." + - **Body** should tell people the impact of the action they are about to take + - **Body** should **not** say "You are about to..." + - **Body** shouldn't just repeat the header + - **Primary button** should restate the action, either repeating the header or a shortened form of the header + - **Primary button** should **not** use ambiguous words like "Confirm" or "Okay" + + :::tip + **Do** + ![Do](/../../assets/images/confirm-dialog-DO.png "Do") + ![Do](/../../assets/images/confirm-dialog-DO-2.png "Do") + - Do keep the header short and restate the action you want people to confirm + - Do repeat the header in the primary button + - Do end with a question mark + ::: + + :::danger + **Don't** + ![Don't](/../../assets/images/confirm-dialog-DONT.png "Don't") + ![Don't](/../../assets/images/confirm-dialog-DONT-2.png "Don't") + - Don't wrap the header to multiple lines + - Don't use title case + - Don't ask "are you sure," in the header or the body + - Don't use ambiguous button text like "Confirm" or "Don't confirm" + ::: --- ## Examples ### Basic Dialog +A basic dialog has a header, body, and footer with one or more buttons that people can use to either move forward with an action or dismiss the dialog. + +Use the `label` attribute to add a dialog header. Add `slot="footer"` to each button you want to appear in the dialog's footer. + ```html:preview - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Cancel - Save + + This is the dialog’s body. + Secondary button + Primary button Open basic dialog @@ -31,11 +72,11 @@ layout: component ``` ```pug:slim -sl-dialog label="Basic dialog" class="dialog-basic" - | Lorem ipsum dolor sit amet, consectetur adipiscing elit. - sl-button slot="footer" variant="default" Cancel - sl-button slot="footer" variant="primary" Save -sl-button Open Dialog +sl-dialog label="Dialog header" class="dialog-basic" + | This is the dialog’s body. + sl-button slot="footer" variant="default" Secondary button + sl-button slot="footer" variant="primary" Primary button +sl-button Open basic dialog javascript: const dialog = document.querySelector(.dialog-basic); @@ -58,7 +99,7 @@ const App = () => { return ( <> - setOpen(false)}> + setOpen(false)}> Lorem ipsum dolor sit amet, consectetur adipiscing elit. setOpen(false)}> Cancel @@ -74,23 +115,40 @@ const App = () => { }; ``` -### Dialog with Icon +### Dialog with Header Icon -Use the `header-icon` slot to display an `sl-icon` to the left of the dialog label (title). Set the dialog variant (`default` or `warning`) to apply a color theme to the icon. +Use the `header-icon` slot to display an `sl-icon` to the left of the dialog header (`label`). + +Use this pattern for confirmation dialogs, when asking people to confirm that they want to take an action, and for informational dialogs. + +Set the dialog variant (`default` or `warning`) to apply the right color theme to the icon: `default` for confirmation of neutral actions (like submitting a form), and `warning` for confirmation of destructive actions (like canceling or deleting something). :::warning -**Note:** When using the `warning` variant of the dialog, be sure to use the button variant `warning` for the dialog's primary action button. +**Note:** For `warning` confirmation dialogs, always use the `warning` button for the dialog's primary action and the icon `exclamation-triangle`. For `default` confirmation dialogs, use the `primary` button and the icon `exclamation-circle`. For `default` informational dialogs, use the icon `info-circle`. ::: ```html:preview - - - If you need to, you'll be able to cancel this request after submitting it. + + +
    What is vesting?
    +
    Vesting refers to the process by which an employee gains ownership rights over employer-provided stock or stock options over a specified period of time.
    +
    This is often contingent upon meeting certain conditions such as continued employment or achieving performance milestones.
    +
    + +Open default informational dialog +
    +
    + + + + If you need to, you can cancel this request after submitting it. Cancel Submit request -Open default dialog +Open default confirmation dialog +
    +
    @@ -99,16 +157,21 @@ Use the `header-icon` slot to display an `sl-icon` to the left of the dialog lab Cancel request -Open warning dialog +Open warning confirmation dialog + + +``` + +```pug:slim +sl-dialog size="small" no-header class="spinner-dialog" + .wrapper + div class="ts-heading-6" Cancelling the transaction + sl-spinner size=""x-large" + +sl-button Open wrapper dialog + +javascript: + const dialog = document.querySelector(.dialog-header-actions); + const openButton = dialog.nextElementSibling; + + openButton.addEventListener(click, () => dialog.show()); + +// If using as a loader, use below script to prevent the dialog from closing when the user clicks on the overlay + /* dialog.addEventListener('sl-request-close', event => { + if (event.detail.source === 'overlay') { + event.preventDefault(); + } + }); */ + +css: + .wrapper { + text-align: center; + padding-top: 2rem; + } + + .wrapper div:first-child { + padding-bottom: 2rem; + } +``` + +```jsx:react +import { useState } from 'react'; +import SlButton from '@teamshares/shoelace/dist/react/button'; +import SlDialog from '@teamshares/shoelace/dist/react/dialog'; +import SlIconButton from '@teamshares/shoelace/dist/react/icon-button'; + +const App = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(false)}> + window.open(location.href)} + /> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + setOpen(false)}> + Close + + + + setOpen(true)}>Open Dialog + + ); +}; +``` + ### Header Actions The header shows a functional close button by default. You can use the `header-actions` slot to add additional [icon buttons](/components/icon-button) if needed. +:::warning +Dialogs with header actions are currently not part of the Teamshares Design System, and there is no Figma component for this option. Please check with the design team before using this option. +::: + ```html:preview - + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Close -Open Dialog +Open dialog with header actions ``` @@ -239,10 +432,15 @@ form.validation javascript: const form = document.querySelector(.validation); - // Handle form submit - form.addEventListener(submit, event => { - event.preventDefault(); - alert(All fields are valid!); + // Wait for controls to be defined before attaching form listeners + await Promise.all([ + customElements.whenDefined('sl-radio-group'), + ]).then(() => { + // Handle form submit + form.addEventListener(submit, event => { + event.preventDefault(); + alert(All fields are valid!); + }); }); ``` @@ -294,7 +492,7 @@ Use the `setCustomValidity()` method to set a custom validation message. This wi Submit - ``` @@ -343,10 +545,14 @@ javascript: radioGroup.setCustomValidity(isValid ? : errorMessage); }); - // Handle form submit - form.addEventListener(submit, event => { - event.preventDefault(); - alert(All fields are valid!); + await Promise.all([ + customElements.whenDefined('sl-radio-group'), + ]).then(() => { + // Handle form submit + form.addEventListener(submit, event => { + event.preventDefault(); + alert(All fields are valid!); + }); }); ``` diff --git a/docs/pages/components/radio.md b/docs/pages/components/radio.md index 81ba76c606..aa9d5f3316 100644 --- a/docs/pages/components/radio.md +++ b/docs/pages/components/radio.md @@ -3,6 +3,10 @@ meta: title: Radio description: Radios allow the user to select a single option from a group. layout: component +unusedProperties: | + - Sizes `small`, `large` +guidelines: | + - Refer to the [Radio Group component general guidelines](/components/radio-group/#usage-guidelines) --- ## Examples @@ -12,18 +16,18 @@ layout: component Radios are designed to be used with [radio groups](/components/radio-group). ```html:preview - - Option 1 - Option 2 - Option 3 + + Issue shares + Employee buyback + Cancel a certificate ``` ```pug:slim -sl-radio-group label="Select an option" name="a" value="1" - sl-radio value="1" Option 1 - sl-radio value="2" Option 2 - sl-radio value="3" Option 3 +sl-radio-group label="What would you like to do?" name="a" value="issue_shares" required + sl-radio value="issue_shares" Issue shares + sl-radio value="employee_buyback" Employee buyback + sl-radio value="cancel_certificate" Cancel a certificate ``` ```jsx:react @@ -43,23 +47,171 @@ const App = () => ( This component works with standard `
    ` elements. Please refer to the section on [form controls](/getting-started/form-controls) to learn more about form submission and client-side validation. ::: -### Initial Value +### Description + +Add descriptive help text to individual radio items with the `description` attribute. For descriptions that contain HTML, use the `description` slot instead. + +```html:preview + + Issue shares + Employee buyback + Cancel a certificate +
    Declares certificate to be null and void
    +
    +
    +``` + +```pug:slim +sl-radio-group label="What would you like to do?" name="a" value="issue_shares" required + sl-radio value="issue_shares" description="Awards company shares to an employee" Issue shares + sl-radio value="employee_buyback" description="Buys back vested shares from departing employee owners" Employee buyback + sl-radio value="cancel_certificate" + | Cancel a certificate + div slot="description" Declares certificate to be + em null and void +``` + +```jsx:react +import SlRadio from '@teamshares/shoelace/dist/react/radio'; +import SlRadioGroup from '@teamshares/shoelace/dist/react/radio-group'; + +const App = () => ( + + Option 1 + Option 2 + Option 3 + +); +``` + +### Contained -To set the initial value and checked state, use the `value` attribute on the containing radio group. +Add the `contained` attribute to the [Radio Group](/components/radio-group) to draw a card-like container around each radio item in the radio group. This style is useful for giving more emphasis to the list of options. ```html:preview - - Option 1 - Option 2 - Option 3 + + Issue shares + Employee buyback + Cancel a certificate +
    Declares certificate to be null and void
    +
    ``` ```pug:slim sl-radio-group label="Select an option" name="a" value="3" - sl-radio value="1" Option 1 - sl-radio value="2" Option 2 - sl-radio value="3" Option 3 + sl-radio contained="true" value="1" Option 1 + sl-radio contained="true" disabled="true" value="2" Option 2 + sl-radio contained="true" value="3" Option 3 + div slot="description" A short description about this option +``` + +```jsx:react +import { SlRadio } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + <> + + + Option 1 + + + Option 2 + + + Option 3
    A short description about this option
    +
    +
    + +); +``` + +:::tip +Adding the `contained` attribute to the parent [Radio Group](/components/radio) or to _any_ radio in the group will create `contained` radios for the entire group. +::: + +### Selected Content + +Use the `selected-content` slot to display additional content (such as an input field) inside a `contained` radio when the radio is selected (checked). The slot is unstyled by default. Use `::part(selected-content)` to style the content as needed. + +```html:preview + + Last statement balance +
    This is the amount you owe as of your Feb 21 statement
    +
    + Current balance +
    This is the amount you owe as of today
    +
    + + Custom amount + + +
    + + +``` + +```pug:slim +sl-radio-group label="Select your payment amount" name="a" value="statement-balance" contained + sl-radio value="statement-balance" description="$10.00" Last statement balance + div slot="selected-content" This is the amount you owe as of your Feb 21 statement + sl-radio value="current-balance" description="$150.00" Current balance + div slot="selected-content" This is the amount you owe as of today + sl-radio value="custom" + | Custom amount + sl-input style="width: 240px;" slot="selected-content" label="Amount" type="currency" + +css: + sl-radio::part(selected-content) { + font-size: 14px; + font-weight: normal; + color: #6D7176; + } +``` + +```jsx:react +import { SlRadio } from '@teamshares/shoelace/dist/react'; + +const App = () => ( + <> + + + Option 1 + + + Option 2 + + + Option 3
    A short description about this option
    +
    +
    + +); +``` + +### Initial Value + +To set the initial value and checked state, use the `value` attribute on the containing radio group. Generally a radio group should have one item selected by default. + +```html:preview + + Issue shares + Employee buyback + Cancel a certificate + +``` + +```pug:slim +sl-radio-group label="What would you like to do?" name="a" value="issue_shares" + sl-radio value="issue_shares" Issue shares + sl-radio value="employee_buyback" Employee buyback + sl-radio value="cancel_certificate" Cancel a certificate ``` ```jsx:react @@ -80,18 +232,18 @@ const App = () => ( Use the `disabled` attribute to disable a radio. ```html:preview - - Option 1 - Option 2 - Option 3 + + Issue shares + Employee buyback + Cancel a certificate ``` ```pug:slim -sl-radio-group label="Select an option" name="a" value="1" - sl-radio value="1" Option 1 - sl-radio value="2" disabled="true" Option 2 - sl-radio value="3" Option 3 +sl-radio-group label="What would you like to do?" name="a" value="issue_shares" + sl-radio value="issue_shares" Issue shares + sl-radio value="employee_buyback" Employee buyback + sl-radio value="cancel_certificate" disabled="true" Cancel a certificate ``` ```jsx:react @@ -109,7 +261,7 @@ const App = () => ( ); ``` -### Sizes + diff --git a/docs/pages/components/select.md b/docs/pages/components/select.md index 22504f600b..96fe230dfc 100644 --- a/docs/pages/components/select.md +++ b/docs/pages/components/select.md @@ -7,21 +7,26 @@ unusedProperties: | - Size `small` - Booleans `filled`, `pill` guidelines: | - **When to use a select** - - Use a select when you need to present the user with more options than would be reasonable to include in a radio group, which generally should have no more than 5 to 7 options - - If there are fewer than 3 options to present, consider whether a radio group would create a better experience for the user - - **Placeholder text and default selections** - - Don't use placeholder text in a select, even to create a default non-selectable option that serves as a hint (e.g. "Select an option") - - If you need to allow the user to clear their selection, include an empty (no value) option to serve as the default "empty" option - - Whenever possible, set a default selection that makes sense for the user and the context - - **Using the multi-select option** - - Use the multi-select option sparingly. Selects that allow the user to choose multiple options are not as common, and users often don't realize that they can choose more than one option. - - Consider whether a checkbox group would create a more straightforward experience for the user - - If you are opting to use the multi-select option, be sure to include a clear button using the `clearable` attribute, so that users can easily clear their selections - - **Labels, Help Text, Placeholder, etc.** + **When to Use a Select** + - When presenting **more than 7** options for people to choose from + - When you **don't have enough space** to present all the options + - Most commonly, when you want people to **choose just one** option + + **When to Use a Different Component** + - **Use a [radio group](/components/radio-group) instead** if presenting fewer than 5 to 7 options and you want to let people choose just **one** option + - **Use a [checkbox group](/components/checkbox-group) instead** if presenting fewer than 5 to 7 options and you want to let people choose **multiple** options + + **Placeholder Text and Default Selections** + - **Don't use placeholder text** in a select, even to create a default non-selectable option that serves as a hint (e.g. "Select an option") + - If you need to allow people to **clear their selection**, include an empty (no value) option to serve as the default "empty" option + - Whenever possible, set a **default selection** that makes sense for the use case and context + + **Using the Multi-select Option** + - **Use the multi-select option sparingly.** Selects that allow people to choose multiple options are not as common, and people often don't realize that they can choose more than one option. + - Consider whether a checkbox group would create a more straightforward experience + - If you are opting to use the multi-select option, be sure to include a clear button using the `clearable` attribute, so that people can easily clear their selections + + **Labels, Help Text, Placeholder, Etc.** - For additional guidelines on select **labels**, **help text**, **label tooltip**, **context note**, and **placeholder text**, refer to the [Input component usage guidelines](/components/input/#usage-guidelines) --- @@ -129,7 +134,7 @@ const App = () => ( Use the `label-tooltip` attribute to add text that appears in a tooltip triggered by an info icon next to the label. :::tip -**Usage:** Use a **label tooltip** to provide helpful but non-essential instructions or examples to guide the user when selecting an option. Use **help text** to communicate instructions or requirements for choosing an option without errors. +**Usage:** Use a **label tooltip** to provide helpful but non-essential instructions or examples to guide people when selecting an option. Use **help text** to communicate instructions or requirements for choosing an option without errors. ::: ```html:preview @@ -167,7 +172,7 @@ const App = () => ( Use the `context-note` attribute to add text that provides additional context or reference. For text that contains HTML, use the `context-note` slot. **Note:** On small screens the context note will wrap below the label if there isn't enough room next to the label. :::tip -**Usage:** Use a **context note** to provide secondary contextual data, especially dynamic data, that would help the user when choosing an option. Use **help text** to communicate instructions or requirements for choosing an option without errors. +**Usage:** Use a **context note** to provide secondary contextual data, especially dynamic data, that would help people when choosing an option. Use **help text** to communicate instructions or requirements for choosing an option without errors. ::: ```html:preview @@ -235,11 +240,11 @@ const App = () => ( Use the `clearable` attribute to make the control clearable. The clear button only appears when an option is selected. :::tip -**Usage:** Add a clear button only when **multiple** options can be selected. For the default single-choice use case (the most common for selects), include an empty option that the user can select to "clear" the current selection. +**Usage:** Add a clear button only when **multiple** options can be selected. For the default single-choice use case (the most common for selects), include an empty option that people can select to "clear" the current selection. ::: ```html:preview - + Option 1 Option 2 Option 3 @@ -248,7 +253,7 @@ Use the `clearable` attribute to make the control clearable. The clear button on Option 6
    - + Option 1 Option 2 @@ -260,7 +265,7 @@ Use the `clearable` attribute to make the control clearable. The clear button on ``` ```pug:slim -sl-select label="Clearable select" clearable="true" multiple="true" value="option-1 option-2" help-text="For multi-choice selects only, display an icon button to let the user clear their selections" +sl-select label="Clearable select" clearable="true" multiple="true" value="option-1 option-2" help-text="For multi-choice selects only, display an icon button to let people clear their selections" sl-option value="option-1" Option 1 sl-option value="option-2" Option 2 sl-option value="option-3" Option 3 @@ -268,7 +273,7 @@ sl-select label="Clearable select" clearable="true" multiple="true" value="optio sl-option value="option-5" Option 5 sl-option value="option-6" Option 6 br - sl-select label="Clearable default select" help-text="Add an empty value option to allow the user to clear their selection in a single-choice select" + sl-select label="Clearable default select" help-text="Add an empty value option to allow people to clear their selection in a single-choice select" sl-option value="option-1" Option 1 sl-option value="option-2" Option 2 sl-option value="option-3" Option 3 diff --git a/docs/pages/components/switch.md b/docs/pages/components/switch.md index 60a0556b56..b73cf2de51 100644 --- a/docs/pages/components/switch.md +++ b/docs/pages/components/switch.md @@ -3,6 +3,43 @@ meta: title: Switch description: Switches allow the user to toggle an option on or off. layout: component +unusedProperties: | + - Size `large` +guidelines: | + **Switch or Checkbox?** + - Use a switch to let people toggle a setting that takes effect **immediately**, like a light switch + - Use a [checkbox](/components/checkbox) to let people toggle a selection that must be **saved before taking effect** + + **Switch Labels** + + - **Always** have a label for the switch + - Use **sentence case** for labels + - **Don’t** end labels with punctuation + - Keep labels **short and easy to scan** + - Only use a leading verb if it **clarifies** the switch's purpose + - **Avoid** phrases that describe the switch's "on" state (e.g. "Turn on" or "Enable") + - **Don't** update the switch's label dynamically based on its `checked` state + + :::tip + **Do** +
    Shareholder view
    +
    Allow edits
    +
    Require password
    + + - Do use sentence case and keep the label short + - Do use a leading verb if it clarifies what the switch does + ::: + + :::danger + **Don’t** +
    Enable Shareholder View.
    +
    Disable no-edit mode
    +
    Turn on Password Requirement
    + + - Don’t use title case or end with punctuation + - Don’t use negative labels (like "hide" and "disable") that conflict with the “on” state of the switch + - Don't repeat the switch's "on" state in the label + ::: --- ## Examples @@ -10,11 +47,11 @@ layout: component ### Basic Switch ```html:preview -Switch +Shareholder view ``` ```pug:slim -sl-switch Switch +sl-switch Shareholder view ``` ```jsx:react @@ -27,6 +64,32 @@ const App = () => Switch; This component works with standard `` elements. Please refer to the section on [form controls](/getting-started/form-controls) to learn more about form submission and client-side validation. ::: +### Label Position + +Use the `label-position` attribute to change the position of the switch's label. The default position is `right`. + +```html:preview +Shareholder view + +President view + +Admin view +``` + +```pug:slim +sl-switch Shareholder view +sl-divider style="--spacing: 2rem;" +sl-switch label-position="left" President view +sl-divider style="--spacing: 2rem;" +sl-switch label-position="left-justified" Shareholder view +``` + +```jsx:react +import SlSwitch from '@teamshares/shoelace/dist/react/switch'; + +const App = () => Switch; +``` + ### Checked Use the `checked` attribute to activate the switch. @@ -65,22 +128,22 @@ const App = () => Disabled; ### Sizes -Use the `size` attribute to change a switch's size. +Use the `size` attribute to change a switch's size. Size `medium` is the switch's default. + +:::warning +Size `large` is currently not part of the Teamshares Design System, and there is no Figma component for this option. Please check with the design team before using this option. +::: ```html:preview -Small -
    -Medium +Small switch
    -Large +Medium switch (default) ``` ```pug:slim -sl-switch size="small" Small -br -sl-switch size="medium" Medium +sl-switch size="small" Small switch br -sl-switch size="large" Large +sl-switch size="medium" Medium switch (default) ``` ```jsx:react @@ -91,8 +154,6 @@ const App = () => ( Small
    Medium -
    - Large ); ``` @@ -101,12 +162,24 @@ const App = () => ( Add descriptive help text to a switch with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead. +:::warning +**Note:** To avoid awkward alignment of the switch, label, and help text, don't display help text when using `label-position="left"`. +::: + ```html:preview -Label +Shareholder view + +Admin view + +Please avoid doing this ``` ```pug:slim -sl-switch help-text="What should the user know about the switch?" Label +sl-switch help-text="Displays company financials" + | Shareholder view +sl-divider style="--spacing: 2rem;" +sl-switch label-position="left-justified" help-text="Displays network financials and reporting data" + | Shareholder view ``` ```jsx:react @@ -119,8 +192,12 @@ const App = () => Really big +Large custom switch ``` ```pug:slim diff --git a/docs/pages/components/tag.md b/docs/pages/components/tag.md index 9591e60678..52c95d4f30 100644 --- a/docs/pages/components/tag.md +++ b/docs/pages/components/tag.md @@ -4,10 +4,17 @@ meta: description: Tags are used as labels to organize things or to indicate a selection. layout: component guidelines: | - - Use to label or organize items or to show that a certain category of items has been selected (e.g. a search filter) - - To show counts, use the [Badge](/components/badge) component + **Tag Basics** - Tags can be removed (for example, when they are being used to indicate a filter selection), but they aren't otherwise interactive - Don't use tags as buttons + + **When to Use a Tag** + - Use a tag to label or organize items + - Use a tag to show that a certain category of items has been selected (e.g. a search filter) + + **When to Use a Different Component** + - Use a [badge](/components/badge) instead if you need to show counts + - Use a [button](/components/button) instead if you need a clickable element that initiates an action unusedProperties: | - Boolean `pill` --- diff --git a/docs/pages/components/textarea.md b/docs/pages/components/textarea.md index 5b81c7c800..ebf15816ca 100644 --- a/docs/pages/components/textarea.md +++ b/docs/pages/components/textarea.md @@ -67,7 +67,7 @@ const App = () => ; Use the `context-note` attribute to add text that provides additional context or reference. For text that contains HTML, use the `context-note` slot. **Note:** On small screens the context note will wrap below the label if there isn't enough room next to the label. :::tip -**Usage:** Use a **context note** to provide secondary contextual data, especially dynamic data, that would help the user when filling in the textarea. Use **help text** to communicate instructions or requirements for filling in the textarea without errors. +**Usage:** Use a **context note** to provide secondary contextual data, especially dynamic data, that would help people when filling in the textarea. Use **help text** to communicate instructions or requirements for filling in the textarea without errors. ::: ```html:preview diff --git a/docs/pages/components/tooltip.md b/docs/pages/components/tooltip.md index 44569d96bf..1b7558c2a4 100644 --- a/docs/pages/components/tooltip.md +++ b/docs/pages/components/tooltip.md @@ -6,7 +6,7 @@ layout: component guidelines: | - Tooltip content should be additional or supplemental. **Don't put essential information in a tooltip.** - Keep the content simple — ideally just one or two words or a short phrase. If using sentences, try to keep below 2 sentences or 3 lines of text at maximum. - - Tooltips should not contain interactive elements like buttons or links or include elements like imagery. + - Tooltips should not contain interactive elements like buttons and links or include elements like imagery --- ## Examples diff --git a/docs/pages/getting-started/form-controls.md b/docs/pages/getting-started/form-controls.md index 7505a61fdb..54ce48696a 100644 --- a/docs/pages/getting-started/form-controls.md +++ b/docs/pages/getting-started/form-controls.md @@ -74,6 +74,12 @@ The form will not be submitted if a required field is incomplete.
    Check me before submitting +

    + + Option 1 + Option 2 + Option 3 +

    Submit @@ -356,8 +362,23 @@ This example demonstrates custom validation styles using `data-user-invalid` and Dogs Other
    +

    - Accept terms and conditions + + Option 1 + Option 2 + Option 3 + +

    + + + Option 1 + Option 2 + Option 3 + +

    + +Accept terms and conditions Submit Reset @@ -455,7 +476,6 @@ To disable the browser's error messages, you need to cancel the `sl-invalid` eve autocomplete="off" required > - Submit diff --git a/docs/pages/teamshares/changelog.md b/docs/pages/teamshares/changelog.md index 8a7785b6ab..910488f287 100644 --- a/docs/pages/teamshares/changelog.md +++ b/docs/pages/teamshares/changelog.md @@ -4,6 +4,18 @@ - Upstream merge, going from 2.11.2 -> 2.14.0. Most of the changes are pretty minor. See the [upstream changelog](https://shoelace.style/resources/changelog) for details. - Also the following component and documentation page updates: + - Alert + - Change default `sl-toast-stack` placement from top right to bottom right + - Update examples to better align with our pattern usage + - Checkbox and Checkbox Group + - Add new `sl-checkbox-group` component with form component decorators (help text, label with tooltip, context note) and `contained` and `horizontal` options + - Update `sl-checkbox` to make it work with new `sl-checkbox-group` + - Dialog + - Add more examples + - Fix bottom spacing for no-footer dialog + - Fix body spacing for dialog with header icon + - Make body style consistent (`body-1`) across all dialog sizes + - Update body text color to `ts-text-default` (from `ts-text-subdued`) - Input - Add `label-tooltip` and `context-note` slots and attributes - Add new type `currency` with default prefix and suffix elements @@ -12,25 +24,31 @@ - Add icon & usage guidelines - Moved styles from `overrides.css` into component `styles.ts` file - Minor styling updates - - Textarea - - Add `label-tooltip` and `context-note` slots and attributes - - Update documentation examples to call out or hide unused patterns (`filled`, sizes `small`, `large`) + - Menu, Menu Item, Menu Label + - Update documentation examples + - Add icon guidelines + - Moved styles from `overrides.css` into component `styles.ts` file + - Minor styling updates + - Option + - Update documentation examples + - Add icon guidelines - Moved styles from `overrides.css` into component `styles.ts` file - Minor styling updates + - Radio and Radio Group + - Add `horizontal` layout option to Radio Group + - Add new form component decorators help text, label with tooltip, context note) - Select - Add `label-tooltip` and `context-note` slots and attributes - Update documentation examples to call out or hide unused patterns (`pill`, `filled`, size `small`) - Add icon & usage guidelines - Moved styles from `overrides.css` into component `styles.ts` file - Minor styling updates - - Option - - Update documentation examples - - Add icon guidelines - - Moved styles from `overrides.css` into component `styles.ts` file - - Minor styling updates - - Menu, Menu Item, Menu Label - - Update documentation examples - - Add icon guidelines + - Switch + - Add new `label-position` options: `left` and `left-justified` + - Update examples + - Textarea + - Add `label-tooltip` and `context-note` slots and attributes + - Update documentation examples to call out or hide unused patterns (`filled`, sizes `small`, `large`) - Moved styles from `overrides.css` into component `styles.ts` file - Minor styling updates - Misc @@ -40,6 +58,7 @@ - `x-circle-fill` (used in `clearable` Input) - `chevron-down` (used in Select) - `checked` (used in Checkbox) + - `x-lg` (used in Alert, Dialog, Drawer, Tab, and Tag) - Add new system icon `checked-option` for use in Option and Menu to show selected options and menu items ## 2.0.2 diff --git a/src/components/checkbox-group/checkbox-group.component.ts b/src/components/checkbox-group/checkbox-group.component.ts new file mode 100644 index 0000000000..07a50acfd2 --- /dev/null +++ b/src/components/checkbox-group/checkbox-group.component.ts @@ -0,0 +1,339 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { + customErrorValidityState, + FormControlController, + validValidityState, + valueMissingValidityState +} from '../../internal/form.js'; +import { HasSlotController } from '../../internal/slot.js'; +import { html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { watch } from '../../internal/watch.js'; +import componentStyles from '../../styles/component.styles.js'; +import formControlStyles from '../../styles/form-control.styles.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './checkbox-group.styles.js'; +import type { CSSResultGroup } from 'lit'; +import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; +import type SlCheckbox from '../checkbox/checkbox.js'; + +/** + * @summary Checkbox groups are used to group multiple [checkboxes](/components/checkbox) so they function as a single form control. + * @documentation https://shoelace.style/components/checkbox-group + * @status experimental + * @since 2.0 + * @pattern stable + * @figma ready + * + * @slot - The default slot where `` elements are placed. + * @slot label - The checkbox group's label. Required for proper accessibility. Alternatively, you can use the `label` attribute. + * @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute. + * @slot help-text - Text that describes how to use the checkbox group. Alternatively, you can use the `help-text` attribute. + * + * @event sl-change - Emitted when the checkbox group's selected value changes. + * @event sl-input - Emitted when the checkbox group receives user input. + * @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. + * + * @csspart form-control - The form control that wraps the label, input, and help text. + * @csspart form-control-label - The label's wrapper. + * @csspart form-control-input - The input's wrapper. + * @csspart form-control-help-text - The help text's wrapper. + */ +export default class SlCheckboxGroup extends ShoelaceElement implements ShoelaceFormControl { + static styles: CSSResultGroup = [componentStyles, formControlStyles, styles]; + + protected readonly formControlController = new FormControlController(this); + private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); + private customValidityMessage = ''; + private validationTimeout: number; + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @query('.checkbox-group__validation-input') validationInput: HTMLInputElement; + + @state() private errorMessage = ''; + + /** + * The checkbox group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot instead. */ + @property() label = ''; + + /** Text that appears in a tooltip next to the label. If you need to display HTML in the tooltip, use the `label-tooltip` slot instead. */ + @property({ attribute: 'label-tooltip' }) labelTooltip = ''; + + /** The checkbox groups's help text. If you need to display HTML, use the `help-text` slot instead. */ + @property({ attribute: 'help-text' }) helpText = ''; + + /** The name of the checkbox group, submitted as a name/value pair with form data. */ + @property() name = ''; + + /** + * The current value of the checkbox group, submitted as a name/value pair with form data. + */ + @property({ type: Array }) value: string[] = []; + + /** The radio group's size. This size will be applied to all child radios and radio buttons. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; + + /** The radio group's orientation. Changes the group's layout from the default (vertical) to horizontal. */ + @property({ type: Boolean, reflect: true }) horizontal = false; + + /** The radio group's style. Changes the group's style from the default (plain) style to the 'contained' style. This style will be applied to all child radios (but not child radio buttons, which do not have the 'contained' style as an option). */ + @property({ type: Boolean, reflect: true }) contained = false; + + /** + * By default, form controls are associated with the nearest containing `
    ` element. This attribute allows you + * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in + * the same document or shadow root for this to work. + */ + @property({ reflect: true }) form = ''; + + /** Ensures a child radio is checked before allowing the containing form to submit. */ + @property({ type: Boolean, reflect: true }) required = false; + + /** Gets the validity state object */ + get validity() { + const anyCheckboxChecked = this.value.some(value => value.includes('true')); + const isRequiredAndEmpty = this.required && !anyCheckboxChecked; + const hasCustomValidityMessage = this.customValidityMessage !== ''; + + if (hasCustomValidityMessage) { + return customErrorValidityState; + } else if (isRequiredAndEmpty) { + return valueMissingValidityState; + } + + return validValidityState; + } + + /** Gets the validation message */ + get validationMessage() { + const anyCheckboxChecked = this.value.some(value => value.includes('true')); + const isRequiredAndEmpty = this.required && !anyCheckboxChecked; + const hasCustomValidityMessage = this.customValidityMessage !== ''; + + if (hasCustomValidityMessage) { + return this.customValidityMessage; + } else if (isRequiredAndEmpty) { + return this.validationInput.validationMessage; + } + + return ''; + } + + connectedCallback() { + super.connectedCallback(); + const checkboxes = this.getAllCheckboxes(); + checkboxes.forEach(checkbox => { + checkbox.addEventListener('sl-change', this.handleCheckboxClick.bind(this)); + }); + this.initializeValueFromCheckboxes(); + } + + firstUpdated() { + this.updateCheckboxValidity(); + this.formControlController.updateValidity(); + } + + private initializeValueFromCheckboxes() { + const checkboxes = this.getAllCheckboxes(); + this.value = checkboxes.map(checkbox => `${checkbox.value}: ${checkbox.checked}`); + } + + private getAllCheckboxes() { + return [...this.querySelectorAll('sl-checkbox')]; + } + + private handleCheckboxClick(event: MouseEvent) { + const target = event.currentTarget as SlCheckbox; + + if (target.disabled) { + return; + } + + const checkboxes = this.getAllCheckboxes(); + this.value = checkboxes.map(checkbox => `${checkbox.value}: ${checkbox.checked}`); + + this.emit('sl-change'); + this.emit('sl-input'); + this.updateCheckboxValidity(); + } + + private handleInvalid(event: Event) { + this.formControlController.setValidity(false); + this.formControlController.emitInvalidEvent(event); + } + + private async syncCheckboxElements() { + const checkboxes = this.getAllCheckboxes(); + + await Promise.all( + // Sync the checkbox size, validity, and existence of 'contained' style + checkboxes.map(async checkbox => { + await checkbox.updateComplete; + checkbox.size = this.size; + checkbox.horizontal = this.horizontal; + // If one checkbox in a group is 'contained' make sure they're all contained + const isAnyContained = checkboxes.some(containedCheckbox => containedCheckbox.contained); + if (isAnyContained) { + checkboxes.forEach(containedCheckbox => { + containedCheckbox.contained = true; + }); + // Otherwise 'contained' is set through Radio Group + } else { + checkbox.contained = this.contained; + } + }) + ); + } + + private syncCheckboxes() { + if (customElements.get('sl-checkbox')) { + this.syncCheckboxElements(); + } else { + customElements.whenDefined('sl-checkbox').then(() => this.syncCheckboxes()); + } + } + + private updateCheckboxValidity() { + if (this.required) { + const checkboxes = this.getAllCheckboxes(); + const anyCheckboxChecked = this.value.some(value => value.includes('true')); + + checkboxes.forEach(checkbox => { + checkbox.required = !anyCheckboxChecked; + }); + } + } + + @watch('size', { waitUntilFirstUpdate: true }) + handleSizeChange() { + this.syncCheckboxes(); + } + + @watch('value') + handleValueChange() { + if (this.hasUpdated) { + this.updateCheckboxValidity(); + } + } + + /** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ + checkValidity() { + const anyCheckboxChecked = this.value.some(value => value.includes('true')); + const isRequiredAndEmpty = this.required && !anyCheckboxChecked; + const hasCustomValidityMessage = this.customValidityMessage !== ''; + + if (isRequiredAndEmpty || hasCustomValidityMessage) { + this.formControlController.emitInvalidEvent(); + return false; + } + + return true; + } + + /** Gets the associated form, if one exists. */ + getForm(): HTMLFormElement | null { + return this.formControlController.getForm(); + } + + /** Checks for validity and shows the browser's validation message if the control is invalid. */ + reportValidity(): boolean { + const isValid = this.validity.valid; + + this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage; + this.formControlController.setValidity(isValid); + this.validationInput.hidden = true; + clearTimeout(this.validationTimeout); + + if (!isValid) { + // Show the browser's constraint validation message + this.validationInput.hidden = false; + this.validationInput.reportValidity(); + this.validationTimeout = setTimeout(() => (this.validationInput.hidden = true), 10000) as unknown as number; + } + + return isValid; + } + + /** Sets a custom validation message. Pass an empty string to restore validity. */ + setCustomValidity(message = '') { + this.customValidityMessage = message; + this.errorMessage = message; + this.validationInput.setCustomValidity(message); + this.formControlController.updateValidity(); + } + + render() { + const hasLabelSlot = this.hasSlotController.test('label'); + const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip'); + const hasHelpTextSlot = this.hasSlotController.test('help-text'); + const hasLabel = this.label ? true : !!hasLabelSlot; + const hasLabelTooltip = this.labelTooltip ? true : !!hasLabelTooltipSlot; + const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const defaultSlot = html` `; + + return html` +
    + + +
    +
    +
    ${this.errorMessage}
    + +
    + ${defaultSlot} +
    + +
    + ${this.helpText} +
    +
    + `; + } +} diff --git a/src/components/checkbox-group/checkbox-group.styles.ts b/src/components/checkbox-group/checkbox-group.styles.ts new file mode 100644 index 0000000000..e6c92869df --- /dev/null +++ b/src/components/checkbox-group/checkbox-group.styles.ts @@ -0,0 +1,47 @@ +import { css } from 'lit'; + +export default css` + :host { + display: block; + line-height: var(--ts-leading-5); + } + + :host([horizontal]) .form-control-input { + display: flex; + column-gap: var(--ts-spacing-2x-large); + } + + :host([contained]) .form-control-input { + margin-top: var(--sl-spacing-medium); + } + + :host([horizontal][contained]) .form-control-input { + margin-top: var(--sl-spacing-medium); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(152px, 1fr)); + gap: var(--sl-spacing-x-small); + } + + .form-control { + position: relative; + border: none; + padding: 0; + margin: 0; + } + + .form-control__label { + padding: 0; + } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +`; diff --git a/src/components/checkbox-group/checkbox-group.test.ts b/src/components/checkbox-group/checkbox-group.test.ts new file mode 100644 index 0000000000..1620b2cf41 --- /dev/null +++ b/src/components/checkbox-group/checkbox-group.test.ts @@ -0,0 +1,324 @@ +import '../../../dist/shoelace.js'; +import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js'; +import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import type { SlChangeEvent } from '../../events/sl-change.js'; +import type SlCheckbox from '../checkbox/checkbox.js'; +import type SlCheckboxGroup from './checkbox-group.js'; + +describe('', () => { + describe('validation tests', () => { + it('should be invalid initially when required and no checkbox is checked', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + + expect(checkboxGroup.checkValidity()).to.be.false; + }); + + it('should become valid when an option is checked', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + + const secondCheckbox = checkboxGroup.querySelectorAll('sl-checkbox')[1]; + secondCheckbox.click(); + await checkboxGroup.updateComplete; + expect(checkboxGroup.checkValidity()).to.be.true; + }); + + it(`should be valid when required and one checkbox is checked`, async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + + expect(el.checkValidity()).to.be.true; + }); + + it(`should be invalid when required and no checkboxes are checked`, async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + + expect(el.checkValidity()).to.be.false; + }); + + it(`should be invalid when custom validity is set`, async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + + el.setCustomValidity('Error'); + + expect(el.checkValidity()).to.be.false; + }); + + it('should receive the correct validation attributes ("states") when valid', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + + const secondCheckbox = checkboxGroup.querySelectorAll('sl-checkbox')[1]; + + expect(checkboxGroup.checkValidity()).to.be.true; + expect(checkboxGroup.hasAttribute('data-required')).to.be.true; + expect(checkboxGroup.hasAttribute('data-optional')).to.be.false; + expect(checkboxGroup.hasAttribute('data-invalid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-valid')).to.be.true; + expect(checkboxGroup.hasAttribute('data-user-invalid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-user-valid')).to.be.false; + + secondCheckbox.click(); + await secondCheckbox.updateComplete; + + expect(checkboxGroup.checkValidity()).to.be.true; + expect(checkboxGroup.hasAttribute('data-user-invalid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-user-valid')).to.be.true; + }); + + it('should receive the correct validation attributes ("states") when invalid', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + + const secondCheckbox = checkboxGroup.querySelectorAll('sl-checkbox')[1]; + + expect(checkboxGroup.hasAttribute('data-required')).to.be.true; + expect(checkboxGroup.hasAttribute('data-optional')).to.be.false; + expect(checkboxGroup.hasAttribute('data-invalid')).to.be.true; + expect(checkboxGroup.hasAttribute('data-valid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-user-invalid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-user-valid')).to.be.false; + + secondCheckbox.click(); + await secondCheckbox.updateComplete; + secondCheckbox.click(); + await secondCheckbox.updateComplete; + + expect(checkboxGroup.hasAttribute('data-user-invalid')).to.be.true; + expect(checkboxGroup.hasAttribute('data-user-valid')).to.be.false; + }); + + it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => { + const el = await fixture(html` + + + + + + + `); + const checkboxGroup = el.querySelector('sl-checkbox-group')!; + + expect(checkboxGroup.hasAttribute('data-required')).to.be.true; + expect(checkboxGroup.hasAttribute('data-optional')).to.be.false; + expect(checkboxGroup.hasAttribute('data-invalid')).to.be.true; + expect(checkboxGroup.hasAttribute('data-valid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-user-invalid')).to.be.false; + expect(checkboxGroup.hasAttribute('data-user-valid')).to.be.false; + }); + + it('should show a constraint validation error when setCustomValidity() is called', async () => { + const form = await fixture(html` +
    + + + + + Submit +
    + `); + const button = form.querySelector('sl-button')!; + const checkboxGroup = form.querySelector('sl-checkbox-group')!; + const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault()); + + // Submitting the form after setting custom validity should not trigger the handler + checkboxGroup.setCustomValidity('Invalid selection'); + form.addEventListener('submit', submitHandler); + button.click(); + + await aTimeout(100); + + expect(submitHandler).to.not.have.been.called; + }); + }); +}); + +describe('when resetting a form', () => { + it('should reset the element to its initial value', async () => { + const form = await fixture(html` +
    + + + + + Reset +
    + `); + const button = form.querySelector('sl-button')!; + const secondCheckbox = form.querySelectorAll('sl-checkbox')[1]; + secondCheckbox.click(); + + await secondCheckbox.updateComplete; + setTimeout(() => button.click()); + + await oneEvent(form, 'reset'); + await secondCheckbox.updateComplete; + + expect(secondCheckbox.checked).to.be.false; + }); +}); + +describe('when submitting a form', () => { + it('should submit the correct values when one or more values are provided', async () => { + const form = await fixture(html` +
    + + + + + + Submit +
    + `); + const button = form.querySelector('sl-button')!; + const checkbox = form.querySelectorAll('sl-checkbox')[1]!; + const submitHandler = sinon.spy((event: SubmitEvent) => { + formData = new FormData(form); + event.preventDefault(); + }); + let formData: FormData; + + form.addEventListener('submit', submitHandler); + checkbox.click(); + button.click(); + await waitUntil(() => submitHandler.calledOnce); + + expect(formData!.getAll('a')).to.include('1: true'); + expect(formData!.getAll('a')).to.include('2: true'); + }); + + it('should be present in form data when using the form attribute and located outside of a
    ', async () => { + const el = await fixture(html` +
    + + Submit + + + + + + +
    + `); + const form = el.querySelector('form')!; + const formData = new FormData(form); + + expect(formData.getAll('a')).to.include('1: true'); + expect(formData.getAll('a')).to.include('2: true'); + }); +}); + +describe('when the value changes', () => { + it('should emit sl-change and sl-input when toggled with the space bar', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + const firstCheckbox = checkboxGroup.querySelector('#checkbox-1')!; + const changeHandler = sinon.spy(); + const inputHandler = sinon.spy(); + + checkboxGroup.addEventListener('sl-change', changeHandler); + checkboxGroup.addEventListener('sl-input', inputHandler); + firstCheckbox.focus(); + await sendKeys({ press: ' ' }); + checkboxGroup.dispatchEvent(new FocusEvent('blur')); + await checkboxGroup.updateComplete; + + expect(changeHandler).to.have.been.called; + expect(inputHandler).to.have.been.called; + expect(checkboxGroup.value).to.include('1: true'); + expect(checkboxGroup.value).to.include('2: false'); + }); + + it('should emit sl-change and sl-input when clicked', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + const checkbox = checkboxGroup.querySelector('#checkbox-1')!; + const inputHandler = sinon.spy(); + + setTimeout(() => checkbox.click()); + checkboxGroup.addEventListener('sl-input', inputHandler); + const event = (await oneEvent(checkboxGroup, 'sl-change')) as SlChangeEvent; + + expect(event.target).to.equal(checkboxGroup); + expect(inputHandler).to.have.been.called; + expect(checkboxGroup.value).to.include('1: true'); + expect(checkboxGroup.value).to.include('2: false'); + }); + + it('should not emit sl-change or sl-input when the value is changed programmatically', async () => { + const checkboxGroup = await fixture(html` + + + + + `); + + checkboxGroup.addEventListener('sl-change', () => expect.fail('sl-change should not be emitted')); + checkboxGroup.addEventListener('sl-input', () => expect.fail('sl-input should not be emitted')); + checkboxGroup.value = ['1: false', '2: true']; + await checkboxGroup.updateComplete; + }); + + it('should relatively position content to prevent visually hidden scroll bugs', async () => { + // + // See https://github.com/shoelace-style/shoelace/issues/1380 + // + const checkboxGroup = await fixture(html` + + + + `); + + const formControl = checkboxGroup.shadowRoot!.querySelector('.form-control')!; + const visuallyHidden = checkboxGroup.shadowRoot!.querySelector('.visually-hidden')!; + + expect(getComputedStyle(formControl).position).to.equal('relative'); + expect(getComputedStyle(visuallyHidden).position).to.equal('absolute'); + }); + + runFormControlBaseTests('sl-checkbox-group'); +}); diff --git a/src/components/checkbox-group/checkbox-group.ts b/src/components/checkbox-group/checkbox-group.ts new file mode 100644 index 0000000000..ab0c863700 --- /dev/null +++ b/src/components/checkbox-group/checkbox-group.ts @@ -0,0 +1,12 @@ +import SlCheckboxGroup from './checkbox-group.component.js'; + +export * from './checkbox-group.component.js'; +export default SlCheckboxGroup; + +SlCheckboxGroup.define('sl-checkbox-group'); + +declare global { + interface HTMLElementTagNameMap { + 'sl-checkbox-group': SlCheckboxGroup; + } +} diff --git a/src/components/checkbox/checkbox.component.ts b/src/components/checkbox/checkbox.component.ts index c622d1e10f..98a4200e51 100644 --- a/src/components/checkbox/checkbox.component.ts +++ b/src/components/checkbox/checkbox.component.ts @@ -8,6 +8,7 @@ import { live } from 'lit/directives/live.js'; import { property, query, state } from 'lit/decorators.js'; import { watch } from '../../internal/watch.js'; import componentStyles from '../../styles/component.styles.js'; +import formControlStyles from '../../styles/form-control.styles.js'; import ShoelaceElement from '../../internal/shoelace-element.js'; import SlIcon from '../icon/icon.component.js'; import styles from './checkbox.styles.js'; @@ -25,7 +26,8 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; * @dependency sl-icon * * @slot - The checkbox's label. - * @slot help-text - Text that describes how to use the checkbox. Alternatively, you can use the `help-text` attribute. + * @slot description - A description of the checkbox's label. Serves as help text for a checkbox item. Alternatively, you can use the `description` attribute. + * @slot selected-content - Use to nest rich content (like an input) inside a selected checkbox item. Use only with the contained style. * * @event sl-blur - Emitted when the checkbox loses focus. * @event sl-change - Emitted when the checked state changes. @@ -40,10 +42,11 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element.js'; * @csspart checked-icon - The checked icon, an `` element. * @csspart indeterminate-icon - The indeterminate icon, an `` element. * @csspart label - The container that wraps the checkbox's label. - * @csspart form-control-help-text - The help text's wrapper. + * @csspart description - The container that wraps the checkbox's description. + * @csspart selected-content - The container that wraps optional content that appears when a checkbox is checked. */ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl { - static styles: CSSResultGroup = [componentStyles, styles]; + static styles: CSSResultGroup = [componentStyles, formControlStyles, styles]; static dependencies = { 'sl-icon': SlIcon }; private readonly formControlController = new FormControlController(this, { @@ -51,7 +54,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC defaultValue: (control: SlCheckbox) => control.defaultChecked, setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked) }); - private readonly hasSlotController = new HasSlotController(this, 'help-text'); + private readonly hasSlotController = new HasSlotController(this, 'description'); @query('input[type="checkbox"]') input: HTMLInputElement; @@ -83,6 +86,9 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC /** Draws a container around the checkbox. */ @property({ type: Boolean, reflect: true }) contained = false; + /** Applies styles relevant to checkboxes in a horizontal layout. */ + @property({ type: Boolean, reflect: true }) horizontal = false; + /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue('checked') defaultChecked = false; @@ -96,8 +102,8 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC /** Makes the checkbox a required field. */ @property({ type: Boolean, reflect: true }) required = false; - /** The checkbox's help text. If you need to display HTML, use the `help-text` slot instead. */ - @property({ attribute: 'help-text' }) helpText = ''; + /** The checkbox's help text. If you need to display HTML, use the `description` slot instead. */ + @property({ attribute: 'description' }) description = ''; /** Gets the validity state object */ get validity() { @@ -138,6 +144,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC this.emit('sl-focus'); } + private handleSelectedContentClick(event: MouseEvent) { + // Prevent clicks on selected content from unchecking the checkbox + event.preventDefault(); + } + @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { // Disabled form controls are always valid @@ -191,8 +202,8 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC } render() { - const hasHelpTextSlot = this.hasSlotController.test('help-text'); - const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; + const hasDescriptionSlot = this.hasSlotController.test('description'); + const hasDescription = this.description ? true : !!hasDescriptionSlot; // // NOTE: we use a
    around the label slot because of this Chrome bug. @@ -206,7 +217,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC 'form-control--small': this.size === 'small', 'form-control--medium': this.size === 'medium', 'form-control--large': this.size === 'large', - 'form-control--has-help-text': hasHelpText + 'form-control--checkbox-contained-wrapper': this.contained })} >
    diff --git a/src/components/checkbox/checkbox.styles.ts b/src/components/checkbox/checkbox.styles.ts index 39bd1496c3..0606b8cf53 100644 --- a/src/components/checkbox/checkbox.styles.ts +++ b/src/components/checkbox/checkbox.styles.ts @@ -2,7 +2,18 @@ import { css } from 'lit'; export default css` :host { - display: inline-block; + display: block; + margin-top: var(--sl-spacing-medium); + } + + :host([horizontal]), + :host([contained]) { + margin-top: var(--sl-spacing-small); + } + + :host([horizontal][contained]) { + margin-top: 0; + height: 100%; } .checkbox { @@ -82,6 +93,7 @@ export default css` /* Focus */ .checkbox:not(.checkbox--checked):not(.checkbox--disabled) .checkbox__input:focus-visible ~ .checkbox__control { + border-color: var(--sl-color-primary-500); outline: var(--sl-focus-ring); outline-offset: var(--sl-focus-ring-offset); } @@ -113,45 +125,36 @@ export default css` cursor: not-allowed; } - :host([required]) .checkbox__label::after { - content: var(--sl-input-required-content); - margin-inline-start: var(--sl-input-required-content-offset); + :host-context([required]) .checkbox__label::after { + display: none; } - .checkbox__label { - display: inline-block; - color: var(--sl-input-label-color); - line-height: var(--toggle-size); - margin-inline-start: 0.5em; - user-select: none; - -webkit-user-select: none; + :host([required]) .checkbox__label::after { + display: none; } - .checkbox__label-description-container { + .checkbox__label { display: inline-block; color: var(--sl-input-label-color); line-height: var(--toggle-size); - margin-inline-start: 0.5em; + margin-inline-start: 0.75em; user-select: none; -webkit-user-select: none; } - .checkbox--has-description .checkbox__description-block { - height: var(--sl-spacing-small); - } - /* Contained */ .checkbox--contained { - margin: 0.125rem; - padding: 1.5rem; + padding: 1.375rem var(--ts-spacing-large) 1.375rem var(--sl-spacing-medium); border: 1px solid var(--sl-color-gray-400); border-radius: var(--sl-border-radius-medium); width: 100%; + height: 100%; } .checkbox--contained:hover, .checkbox--contained.checkbox--checked:hover { background-color: var(--sl-color-blue-50); + transition: var(--sl-transition-medium) all; } .checkbox--contained.checkbox--checked .checkbox__label { @@ -159,9 +162,15 @@ export default css` font-weight: var(--ts-font-semibold); } + .checkbox--contained.checkbox--checked .checkbox__description { + color: var(--sl-color-gray-900); + font-weight: var(--sl-font-weight-normal); + } + .checkbox--contained.checkbox--checked { background-color: var(--sl-color-blue-100); border: 1px solid var(--sl-color-blue-600); outline: 1px solid var(--sl-color-blue-600); + transition: var(--sl-transition-medium) all; } `; diff --git a/src/components/checkbox/checkbox.test.ts b/src/components/checkbox/checkbox.test.ts index 0a05a65bfc..abe7e02ba3 100644 --- a/src/components/checkbox/checkbox.test.ts +++ b/src/components/checkbox/checkbox.test.ts @@ -23,7 +23,7 @@ describe('', () => { expect(el.checked).to.be.false; expect(el.indeterminate).to.be.false; expect(el.defaultChecked).to.be.false; - expect(el.helpText).to.equal(''); + expect(el.description).to.equal(''); }); it('should have title if title attribute is set', async () => { @@ -174,7 +174,7 @@ describe('', () => { await clickOnElement(checkbox); await checkbox.updateComplete; - expect(checkbox.hasAttribute('data-user-invalid')).to.be.true; + expect(checkbox.hasAttribute('data-user-invalid')).to.be.false; expect(checkbox.hasAttribute('data-user-valid')).to.be.false; }); diff --git a/src/components/details/details.styles.ts b/src/components/details/details.styles.ts index 2874d798ea..91281cdb48 100644 --- a/src/components/details/details.styles.ts +++ b/src/components/details/details.styles.ts @@ -24,7 +24,7 @@ export default css` display: flex; align-items: center; border-radius: inherit; - padding: var(--sl-spacing-large); + padding: var(--ts-spacing-large); user-select: none; -webkit-user-select: none; cursor: pointer; @@ -85,7 +85,6 @@ export default css` .details__content { display: block; - padding: var(--sl-spacing-large); - padding-top: 0.75rem; + padding: var(--sl-spacing-small) var(--ts-spacing-large) var(--ts-spacing-large); } `; diff --git a/src/components/details/details.test.ts b/src/components/details/details.test.ts index ab489cdcdc..b6aa259db9 100644 --- a/src/components/details/details.test.ts +++ b/src/components/details/details.test.ts @@ -193,7 +193,7 @@ describe('', () => { await first.show(); await second.show(); - expect(firstBody.clientHeight).to.equal(232); // 200 + 16px + 16px (vertical padding) - expect(secondBody.clientHeight).to.equal(432); // 400 + 16px + 16px (vertical padding) + expect(firstBody.clientHeight).to.equal(200); + expect(secondBody.clientHeight).to.equal(400); }); }); diff --git a/src/components/dialog/dialog.component.ts b/src/components/dialog/dialog.component.ts index c736018f51..eb5f3c0d3f 100644 --- a/src/components/dialog/dialog.component.ts +++ b/src/components/dialog/dialog.component.ts @@ -278,6 +278,8 @@ export default class SlDialog extends ShoelaceElement { } render() { + const hasFooter = this.hasSlotController.test('footer'); + return html`
    , but tabindex="-1" on the slot causes children to not be focusable. https://github.com/shoelace-style/shoelace/issues/1753#issuecomment-1836803277 */ } -
    +
    + +