diff --git a/.build/generate-tokens.ts b/.build/generate-tokens.ts index 8f6157649..4e0edf6d2 100644 --- a/.build/generate-tokens.ts +++ b/.build/generate-tokens.ts @@ -18,9 +18,13 @@ const outFile = join(repoRoot, 'shared', 'lib', 'tokens.ts') const mode = process.argv[2] === 'check' ? 'check' : 'generate' // One entry per generated const/type; `source` is the Sass member the keys come -// from — `kind: 'map'` reads map.keys(), `kind: 'list'` reads the list itself. +// from — `kind: 'map'` reads map.keys(), `kind: 'list'` reads the list itself, +// `kind: 'expression'` evaluates `expression` and documents itself with `label`. const TOKENS = [ { name: 'themeColors', type: 'ThemeColor', module: 'v', variable: '$theme-colors', kind: 'map', source: 'core/scss/_variables.scss' }, + // `$theme-colors` is the semantic map with `$extra-colors` merged in; these two split it back + { name: 'semanticColors', type: 'SemanticColor', module: 'v', variable: '$theme-colors', kind: 'expression', expression: 'map.keys(map.remove(v.$theme-colors, map.keys(v.$extra-colors)...))', label: 'Keys of `$theme-colors` without the `$extra-colors` keys', source: 'core/scss/_variables.scss' }, + { name: 'extraColors', type: 'ExtraColor', module: 'v', variable: '$extra-colors', kind: 'map', source: 'core/scss/_variables.scss' }, { name: 'socialColors', type: 'SocialColor', module: 'v', variable: '$social-colors', kind: 'map', source: 'core/scss/_variables.scss' }, { name: 'avatarSizes', type: 'AvatarSize', module: 'v', variable: '$avatar-sizes', kind: 'map', source: 'core/scss/_variables.scss' }, { name: 'aspectRatios', type: 'AspectRatio', module: 'v', variable: '$aspect-ratios', kind: 'map', source: 'core/scss/_variables.scss' }, @@ -37,7 +41,7 @@ const entry = ` @use 'variables' as v; @use 'settings' as s; @use 'ui/patterns' as p; -${TOKENS.map((t) => `@debug 'TOKEN ${t.name}=#{${t.kind === 'map' ? `map.keys(${t.module}.${t.variable})` : `${t.module}.${t.variable}`}}';`).join('\n')} +${TOKENS.map((t) => `@debug 'TOKEN ${t.name}=#{${'expression' in t ? t.expression : t.kind === 'map' ? `map.keys(${t.module}.${t.variable})` : `${t.module}.${t.variable}`}}';`).join('\n')} ` const keysByName = new Map() @@ -69,7 +73,7 @@ for (const token of TOKENS) { process.exit(1) } output += ` -/** Keys of \`${token.variable}\` (${token.source}). */ +/** ${'label' in token ? token.label : `Keys of \`${token.variable}\``} (${token.source}). */ export const ${token.name} = [${keys.map((key) => `'${key}'`).join(', ')}] as const export type ${token.type} = (typeof ${token.name})[number] ` diff --git a/.changeset/fix-tom-select-focus-height.md b/.changeset/fix-tom-select-focus-height.md new file mode 100644 index 000000000..ad049041f --- /dev/null +++ b/.changeset/fix-tom-select-focus-height.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed Tom Select growing taller on focus when the selected item has an indicator (avatar, flag, badge). diff --git a/.changeset/signal-component.md b/.changeset/signal-component.md new file mode 100644 index 000000000..00c2efc71 --- /dev/null +++ b/.changeset/signal-component.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added the Signal component (`.signal`): stepped bars that show a level such as priority, with a `Signal.astro` wrapper. diff --git a/core/scss/_core.scss b/core/scss/_core.scss index 6e313cf32..50af33123 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -84,6 +84,7 @@ @forward 'ui/markdown'; @forward 'ui/placeholder'; @forward 'ui/segmented'; +@forward 'ui/signal'; @forward 'ui/steps'; @forward 'ui/status'; @forward 'ui/switch-icon'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 5e1e07907..8ee0837a7 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1723,6 +1723,16 @@ $status-indicator-size: 2.5rem !default; $status-circle-size: 0.75rem !default; // scss-docs-end status-variables +// scss-docs-start signal-variables +$signal-size: map.get(map.get($avatar-sizes, 'xs'), size) !default; +$signal-gap-ratio: 0.1 !default; +$signal-padding-y-ratio: 0.2 !default; +$signal-padding-x-ratio: 0.1 !default; +$signal-bar-border-radius: 1px !default; +$signal-bar-inactive-opacity: 0.24 !default; +$signal-max-bars: 5 !default; +// scss-docs-end signal-variables + // Steps // scss-docs-start steps-variables $steps-border-width: 2px !default; diff --git a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap index c9588f5c3..69140ea0e 100644 --- a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap +++ b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap @@ -65,6 +65,7 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre "--tblr-shadow-card", "--tblr-shadow-dropdown", "--tblr-shadow-input", + "--tblr-signal-color", "--tblr-success", "--tblr-tertiary", "--tblr-text-inverted", diff --git a/core/scss/ui/_signal.scss b/core/scss/ui/_signal.scss new file mode 100644 index 000000000..f42533bf0 --- /dev/null +++ b/core/scss/ui/_signal.scss @@ -0,0 +1,55 @@ +@use '../config' as *; + +.signal { + --signal-size: #{$signal-size}; + --signal-color: #{$text-secondary}; + --signal-bars: 3; + --signal-bar-border-radius: #{$signal-bar-border-radius}; + --signal-bar-inactive-opacity: #{$signal-bar-inactive-opacity}; + --signal-gap: calc(var(--signal-size) * #{$signal-gap-ratio}); + --signal-padding-y: calc(var(--signal-size) * #{$signal-padding-y-ratio}); + --signal-padding-x: calc(var(--signal-size) * #{$signal-padding-x-ratio}); + display: inline-flex; + flex-shrink: 0; + gap: var(--signal-gap); + align-items: flex-end; + width: var(--signal-size); + height: var(--signal-size); + padding: var(--signal-padding-y) var(--signal-padding-x); + vertical-align: middle; +} + +// Each bar takes its share of the full height: the first of three is a third, the last is full +.signal-bar { + --signal-bar-index: 1; + flex: 1; + height: calc(100% * var(--signal-bar-index) / var(--signal-bars)); + background: var(--signal-color); + opacity: var(--signal-bar-inactive-opacity); + @include border-radius(var(--signal-bar-border-radius)); + + &.active { + opacity: 1; + } + + @for $index from 2 through $signal-max-bars { + &:nth-child(#{$index}) { + --signal-bar-index: #{$index}; + } + } +} + +// The number of bars is read from the markup, so the steps stay even for any count +@for $count from 2 through $signal-max-bars { + @if $count != 3 { + .signal:has(> .signal-bar:last-child:nth-child(#{$count})) { + --signal-bars: #{$count}; + } + } +} + +@each $name, $color in $theme-colors { + .signal-#{$name} { + --signal-color: var(--#{$name}); + } +} diff --git a/core/scss/vendor/_tom-select.scss b/core/scss/vendor/_tom-select.scss index d3a082530..2171bdb6d 100644 --- a/core/scss/vendor/_tom-select.scss +++ b/core/scss/vendor/_tom-select.scss @@ -135,11 +135,18 @@ $input-border-width: 1px; padding: $dropdown-item-padding-y $dropdown-item-padding-x; } - .active { + // Tom Select marks the highlighted row with .active; match the row only, so an + // .active element inside an option (a lit .signal-bar) keeps its own background + [data-selectable].active { color: var(--body-color) !important; background-color: var(--active-bg) !important; } + // The library's own `.ts-dropdown .active` also hits a lit bar and clears its background + .signal-bar.active { + background-color: var(--signal-color); + } + .active.create { color: var(--body-color) !important; } @@ -155,6 +162,12 @@ $input-border-width: 1px; color: var(--body-color); } +// An item rendered with an indicator reuses `.dropdown-item`, whose `width: 100%` fills the +// control and pushes the search input to a second line on focus +.ts-control > .dropdown-item { + width: auto; +} + .ts-control input { &::placeholder { color: $input-placeholder-color; diff --git a/docs/content/ui/components/signal.mdx b/docs/content/ui/components/signal.mdx new file mode 100644 index 000000000..83abfdd7d --- /dev/null +++ b/docs/content/ui/components/signal.mdx @@ -0,0 +1,163 @@ +--- +title: Signal +summary: A small row of stepped bars that shows a level, such as priority, strength or coverage. +description: Show priority, strength or any other level with the signal component - a compact row of stepped bars that sits next to a label. +related: [/ui/components/status, /ui/components/tracking] +source: core/scss/ui/_signal.scss +classnames: + component: + - class: signal + desc: Container element + part: + - class: signal-bar + desc: One bar; from two to five bars are supported + state: + - class: active + desc: Lights a bar, set on signal-bar + color: + - class: signal-{color} + desc: Colors the bars with a theme color +--- +import Example from '@components/Example.astro'; +import CodeDocs from '@components/CodeDocs.astro'; + +## Basic example + +Build a signal from a `.signal` wrapper with three `.signal-bar` elements. Add `.active` to each bar that should be lit. The bars grow in even steps, and the unlit ones stay visible in a lighter tone. + + + + + + + + + +## With a label + +Put the signal next to a text label. When the label already names the level, hide the signal from assistive technology with `aria-hidden="true"`. + + +
+
+ + Urgent +
+
+ + Normal +
+
+ + Low +
+
+
+ +## Colors + +Add a `.signal-{color}` class to color the bars. Without it, the signal uses the secondary text color. + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +## Number of bars + +Use from two to five bars. The component counts them and keeps the steps even, so no extra class is needed. + + +
+ + + + + + + + + + + + + + + + + +
+
+ +## Size + +A signal is a `1.25rem` square, the same as an extra small [avatar](/ui/components/avatar). Set `--tblr-signal-size` on the signal to resize it. + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +## Accessibility + +- Show the level with the number of lit bars, not with color alone. An orange and a green signal with the same bars look identical to many users. +- A signal without a text label needs `role="img"` and an `aria-label` that states the level, for example "Priority: urgent". +- A signal next to a label that already names the level is decoration. Hide it with `aria-hidden="true"`. + +## SCSS variables + +Use these SCSS variables to customize the signal component. The default values are: + + diff --git a/preview/pages/signal.astro b/preview/pages/signal.astro new file mode 100644 index 000000000..92fc1b801 --- /dev/null +++ b/preview/pages/signal.astro @@ -0,0 +1,136 @@ +--- +import DefaultLayout from '@shared/layouts/DefaultLayout.astro' +import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' +import DocsLink from '@ui/DocsLink.astro' +import Signal from '@ui/Signal.astro' +import { semanticColors, extraColors } from '@shared/lib/tokens' + +const priorities = [ + { label: 'Urgent', value: 3, color: 'orange' }, + { label: 'Normal', value: 2, color: 'blue' }, + { label: 'Low', value: 1, color: 'green' }, + { label: 'None', value: 0, color: undefined }, +] + +// `light` is left out: its bars are invisible on a card +const colorGroups = [ + { title: 'Semantic colors', colors: semanticColors.filter((color) => color !== 'light') }, + { title: 'Extra colors', colors: extraColors }, +] + +const tasks = [ + { name: 'Payment page returns 500 on submit', priority: 'Urgent', value: 3, color: 'red' }, + { name: 'Update the onboarding copy', priority: 'Normal', value: 2, color: 'blue' }, + { name: 'Export invoices to CSV', priority: 'Normal', value: 2, color: 'blue' }, + { name: 'Refresh the team page photos', priority: 'Low', value: 1, color: 'green' }, +] +--- + + + + +
+
+ + + By priority + The number of lit bars carries the level, the color only supports it. +
+ { + priorities.map((item) => ( +
+ + {item.label} +
+ )) + } +
+
+
+
+ +
+ + + Number of bars + Use from two to five .signal-bar elements; the steps stay even. +
+ { + [2, 3, 4, 5].map((max) => ( +
+ + {max - 1} of {max} +
+ )) + } +
+
+
+
+ +
+ + + Sizes + A signal is a 1.25rem square, like an extra small avatar. Set --tblr-signal-size to resize it. +
+ {['1rem', '1.25rem', '1.5rem', '2rem', '2.5rem'].map((size) => )} +
+
+
+
+ +
+ + + Colors + Add .signal-{color} to color the bars. +
+ { + colorGroups.map((group) => ( +
+
{group.title}
+
+ {group.colors.map((color) => ( + + ))} +
+
+ )) + } +
+
+
+
+ +
+ +
+ + + + + + + + + { + tasks.map((task) => ( + + + + + )) + } + +
TaskPriority
{task.name} + {task.priority} +
+
+
+
+
+
diff --git a/screenshots/pages/signal-priority-menu.astro b/screenshots/pages/signal-priority-menu.astro new file mode 100644 index 000000000..ec0f59e29 --- /dev/null +++ b/screenshots/pages/signal-priority-menu.astro @@ -0,0 +1,29 @@ +--- +// Signal in a menu: a priority picker shown open, each item a signal with its +// label. The menu is written open (`show`, static position) because the capture +// cannot click; the fake cursor rests on the active item. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Signal from '@ui/Signal.astro' + +const priorities = [ + { label: 'Urgent', value: 3, color: 'orange' }, + { label: 'Normal', value: 2, color: 'blue', active: true }, + { label: 'Low', value: 1, color: 'green' }, + { label: 'No priority', value: 0, color: undefined }, +] +--- + + + + diff --git a/screenshots/pages/signal-tasks.astro b/screenshots/pages/signal-tasks.astro new file mode 100644 index 000000000..563978d9e --- /dev/null +++ b/screenshots/pages/signal-tasks.astro @@ -0,0 +1,52 @@ +--- +// Signal in context: a task list card where the priority column shows a signal +// next to its label, the way an issue tracker does. Rows are sorted from +// urgent to none, so the lit bars step down the column. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Card from '@ui/Card.astro' +import CardHeader from '@ui/CardHeader.astro' +import Signal from '@ui/Signal.astro' +import Avatar from '@ui/Avatar.astro' +import { personById } from '@shared/lib/people' + +const tasks = [ + { name: 'Payment page returns 500 on submit', priority: 'Urgent', value: 3, color: 'orange', person: 1 }, + { name: 'Fix the broken password reset link', priority: 'Urgent', value: 3, color: 'orange', person: 4 }, + { name: 'Update the onboarding copy', priority: 'Normal', value: 2, color: 'blue', person: 2 }, + { name: 'Export invoices to CSV', priority: 'Normal', value: 2, color: 'blue', person: 6 }, + { name: 'Refresh the team page photos', priority: 'Low', value: 1, color: 'green', person: 3 }, + { name: 'Try a new font for the blog', priority: 'None', value: 0, color: undefined, person: 5 }, +] +--- + + + + +
+ + + + + + + + + + { + tasks.map((task) => ( + + + + + + )) + } + +
TaskPriority
{task.name} + {task.priority} + + +
+
+
+
diff --git a/screenshots/pages/signal.astro b/screenshots/pages/signal.astro new file mode 100644 index 000000000..7f65074ba --- /dev/null +++ b/screenshots/pages/signal.astro @@ -0,0 +1,29 @@ +--- +// Signal component on its own, no card: the four priority levels side by side, +// from no lit bars to all three, each with its label below. The number of lit +// bars carries the level and the color only supports it, so every level gets +// its own color. Enlarged with --tblr-signal-size so the stepped bars read at +// picture size. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Signal from '@ui/Signal.astro' + +const levels = [ + { label: 'None', value: 0, color: undefined }, + { label: 'Low', value: 1, color: 'green' }, + { label: 'Normal', value: 2, color: 'blue' }, + { label: 'Urgent', value: 3, color: 'orange' }, +] +--- + + +
+ { + levels.map((level) => ( +
+ +
{level.label}
+
+ )) + } +
+
diff --git a/shared/components/demo/DataSelect.astro b/shared/components/demo/DataSelect.astro index bc82290ed..b82c2aa6b 100644 --- a/shared/components/demo/DataSelect.astro +++ b/shared/components/demo/DataSelect.astro @@ -14,7 +14,7 @@ interface Props { class?: string; state?: FormValidationState | undefined; placeholder?: string; - /** option decoration: 'avatar' | 'flag' | 'label' */ + /** option decoration: 'avatar' | 'flag' | 'label' | 'signal' */ indicator?: string; /** plain value list — overrides the selects.json lookup */ values?: string[]; @@ -50,6 +50,10 @@ const preselected = new Set( ); const isSelected = (value: string) => preselected.has(value); +// Same markup as ui/Signal.astro, as a string: TomSelect renders the indicator from an attribute. +const signalHtml = (value: number, color?: string) => + ``; + // Build the option list. let options: SelectOption[] = []; let optgroups: SelectOptgroup[] = []; @@ -75,11 +79,12 @@ if (values) { } else if (Array.isArray(data.options)) { options = (data.options as string[]).map((option) => ({ value: option, text: option, selected: isSelected(option) })); } else if (data.options) { - // object of { key: { name, flag?, label?, selected? } } — with optional indicator + // object of { key: { name, flag?, label?, signal?, color?, selected? } } — with optional indicator options = Object.entries(data.options as Record).map(([optKey, v]) => { let custom: string | undefined; if (indicator === 'flag') custom = ``; else if (indicator === 'label') custom = `${v.label}`; + else if (indicator === 'signal') custom = signalHtml(v.signal, v.color); return { value: optKey, text: v.name, custom, selected: Boolean(v.selected) || isSelected(optKey) }; }); } diff --git a/shared/components/forms/FormElements6.astro b/shared/components/forms/FormElements6.astro index 7055b5389..2fd52ca48 100644 --- a/shared/components/forms/FormElements6.astro +++ b/shared/components/forms/FormElements6.astro @@ -76,6 +76,10 @@ const datalistCountries = (flags as { name: string }[]).slice(0, 10) + + + + diff --git a/shared/data/docs.json b/shared/data/docs.json index db6af93bd..4db7df1f2 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -278,6 +278,10 @@ "title": "Segmented control", "url": "/ui/components/segmented-control" }, + { + "title": "Signal", + "url": "/ui/components/signal" + }, { "title": "Sparkline", "url": "/ui/components/sparkline" diff --git a/shared/data/menu.json b/shared/data/menu.json index 328fb434b..f0df71c6d 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -221,6 +221,10 @@ "title": "Scroll spy", "url": "scroll-spy.html" }, + "signal": { + "title": "Signal", + "url": "signal.html" + }, "social": { "title": "Social icons", "url": "social-icons.html" diff --git a/shared/data/selects.json b/shared/data/selects.json index 32497ae54..a0a832bd5 100644 --- a/shared/data/selects.json +++ b/shared/data/selects.json @@ -72,6 +72,30 @@ } } }, + "priorities": { + "value": "normal", + "options": { + "urgent": { + "name": "Urgent", + "signal": 3, + "color": "orange" + }, + "normal": { + "name": "Normal", + "signal": 2, + "color": "blue" + }, + "low": { + "name": "Low", + "signal": 1, + "color": "green" + }, + "none": { + "name": "No priority", + "signal": 0 + } + } + }, "people": { "value": 4, "data": "people" diff --git a/shared/lib/tokens.ts b/shared/lib/tokens.ts index 994afa3c5..56e97a4b2 100644 --- a/shared/lib/tokens.ts +++ b/shared/lib/tokens.ts @@ -5,6 +5,14 @@ export const themeColors = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'light', 'dark', 'muted', 'blue', 'azure', 'indigo', 'purple', 'pink', 'red', 'orange', 'yellow', 'lime', 'green', 'teal', 'cyan'] as const export type ThemeColor = (typeof themeColors)[number] +/** Keys of `$theme-colors` without the `$extra-colors` keys (core/scss/_variables.scss). */ +export const semanticColors = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'light', 'dark', 'muted'] as const +export type SemanticColor = (typeof semanticColors)[number] + +/** Keys of `$extra-colors` (core/scss/_variables.scss). */ +export const extraColors = ['blue', 'azure', 'indigo', 'purple', 'pink', 'red', 'orange', 'yellow', 'lime', 'green', 'teal', 'cyan'] as const +export type ExtraColor = (typeof extraColors)[number] + /** Keys of `$social-colors` (core/scss/_variables.scss). */ export const socialColors = ['x', 'facebook', 'twitter', 'linkedin', 'google', 'youtube', 'vimeo', 'dribbble', 'github', 'instagram', 'pinterest', 'vk', 'rss', 'flickr', 'bitbucket', 'tabler'] as const export type SocialColor = (typeof socialColors)[number] diff --git a/shared/ui/Signal.astro b/shared/ui/Signal.astro new file mode 100644 index 000000000..f9a327ddd --- /dev/null +++ b/shared/ui/Signal.astro @@ -0,0 +1,23 @@ +--- +// Signal (.signal): a row of stepped bars showing a level — priority, strength, coverage. + +interface Props { + /** number of lit bars (`.signal-bar.active`) */ + value?: number + /** total number of bars; core/scss/ui/_signal.scss styles up to $signal-max-bars (5) */ + max?: number + /** core/scss/ui/_signal.scss `.signal-{color}` */ + color?: string | undefined + /** accessible name; without it the signal is hidden from assistive technology */ + ariaLabel?: string + class?: string + /** remaining attributes (id, data-*, …) are forwarded to the root element */ + [key: string]: unknown +} + +const { value = 0, max = 3, color, ariaLabel, class: className, ...rest } = Astro.props + +const bars = Array.from({ length: max }, (_, index) => index < value) +--- + +{bars.map((active) => )}