Skip to main content

stratum_core/
id.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2
3/// Thread-safe unique ID generator for ARIA attribute cross-references.
4///
5/// Every interactive component needs unique, stable IDs for ARIA attributes
6/// like `aria-controls`, `aria-labelledby`, and `aria-describedby`.
7///
8/// IDs are generated with a configurable prefix and an atomic counter,
9/// producing values like `"stratum-btn-001"`, `"stratum-dialog-002"`.
10pub struct IdGenerator {
11    prefix: String,
12    counter: AtomicU64,
13}
14
15impl std::fmt::Debug for IdGenerator {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        f.debug_struct("IdGenerator")
18            .field("prefix", &self.prefix)
19            .field("counter", &self.counter.load(Ordering::Relaxed))
20            .finish()
21    }
22}
23
24impl IdGenerator {
25    /// Create a new ID generator with the given prefix.
26    ///
27    /// # Example
28    /// ```
29    /// use stratum_core::IdGenerator;
30    /// let id_gen = IdGenerator::new("btn");
31    /// let id = id_gen.next();
32    /// assert!(id.starts_with("stratum-btn-"));
33    /// ```
34    pub fn new(prefix: impl Into<String>) -> Self {
35        Self {
36            prefix: prefix.into(),
37            counter: AtomicU64::new(0),
38        }
39    }
40
41    /// Generate the next unique ID.
42    pub fn next(&self) -> String {
43        let n = self.counter.fetch_add(1, Ordering::Relaxed);
44        format!("stratum-{}-{:03}", self.prefix, n)
45    }
46
47    /// Generate a paired set of IDs for label + target relationships.
48    ///
49    /// Returns `(label_id, target_id)` — useful for `aria-labelledby`
50    /// and `aria-controls` relationships.
51    pub fn paired(&self) -> (String, String) {
52        let n = self.counter.fetch_add(1, Ordering::Relaxed);
53        (
54            format!("stratum-{}-{:03}-label", self.prefix, n),
55            format!("stratum-{}-{:03}-target", self.prefix, n),
56        )
57    }
58
59    /// Generate a set of related IDs for multi-part components.
60    ///
61    /// For example, a Disclosure needs a trigger ID and content ID.
62    pub fn group(&self, suffixes: &[&str]) -> Vec<String> {
63        let n = self.counter.fetch_add(1, Ordering::Relaxed);
64        suffixes
65            .iter()
66            .map(|suffix| format!("stratum-{}-{:03}-{}", self.prefix, n, suffix))
67            .collect()
68    }
69
70    /// Get the current counter value (for testing).
71    pub fn current_count(&self) -> u64 {
72        self.counter.load(Ordering::Relaxed)
73    }
74}
75
76/// Global ID generators for each component type.
77///
78/// Using separate generators per component type keeps IDs readable
79/// and predictable in tests.
80pub mod generators {
81    use super::IdGenerator;
82    use std::sync::LazyLock;
83
84    pub static BUTTON: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("btn"));
85    pub static CHECKBOX: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("chk"));
86    pub static RADIO: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("radio"));
87    pub static SWITCH: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("switch"));
88    pub static SLIDER: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("slider"));
89    pub static DISCLOSURE: LazyLock<IdGenerator> =
90        LazyLock::new(|| IdGenerator::new("disclosure"));
91    pub static DIALOG: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("dialog"));
92    pub static POPOVER: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("popover"));
93    pub static TOOLTIP: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("tooltip"));
94    pub static MENU: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("menu"));
95    pub static SELECT: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("select"));
96    pub static TABS: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("tabs"));
97    pub static ACCORDION: LazyLock<IdGenerator> =
98        LazyLock::new(|| IdGenerator::new("accordion"));
99    pub static FORM: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("form"));
100    pub static INPUT: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("input"));
101    pub static TOAST: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("toast"));
102    pub static TABLE: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("table"));
103    pub static TREE: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("tree"));
104    pub static TOGGLE: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("toggle"));
105    pub static PRESSABLE: LazyLock<IdGenerator> =
106        LazyLock::new(|| IdGenerator::new("pressable"));
107    pub static FOCUS_SCOPE: LazyLock<IdGenerator> =
108        LazyLock::new(|| IdGenerator::new("focus-scope"));
109    pub static SEPARATOR: LazyLock<IdGenerator> =
110        LazyLock::new(|| IdGenerator::new("separator"));
111    pub static PORTAL: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("portal"));
112    pub static GENERIC: LazyLock<IdGenerator> = LazyLock::new(|| IdGenerator::new("id"));
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn id_generator_sequential() {
121        let id_gen = IdGenerator::new("test");
122        assert_eq!(id_gen.next(), "stratum-test-000");
123        assert_eq!(id_gen.next(), "stratum-test-001");
124        assert_eq!(id_gen.next(), "stratum-test-002");
125    }
126
127    #[test]
128    fn id_generator_paired() {
129        let id_gen = IdGenerator::new("field");
130        let (label, target) = id_gen.paired();
131        assert_eq!(label, "stratum-field-000-label");
132        assert_eq!(target, "stratum-field-000-target");
133    }
134
135    #[test]
136    fn id_generator_group() {
137        let id_gen = IdGenerator::new("disclosure");
138        let ids = id_gen.group(&["trigger", "content"]);
139        assert_eq!(ids.len(), 2);
140        assert_eq!(ids[0], "stratum-disclosure-000-trigger");
141        assert_eq!(ids[1], "stratum-disclosure-000-content");
142    }
143
144    #[test]
145    fn id_generator_thread_safe() {
146        use std::thread;
147
148        let id_gen = IdGenerator::new("thread");
149        let id_ref = &id_gen;
150
151        thread::scope(|s| {
152            let mut handles = vec![];
153            for _ in 0..10 {
154                handles.push(s.spawn(|| id_ref.next()));
155            }
156            let ids: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
157            // All IDs should be unique
158            let mut sorted = ids.clone();
159            sorted.sort();
160            sorted.dedup();
161            assert_eq!(sorted.len(), ids.len());
162        });
163    }
164
165    #[test]
166    fn global_generators_unique() {
167        let btn_id = generators::BUTTON.next();
168        let chk_id = generators::CHECKBOX.next();
169        assert!(btn_id.starts_with("stratum-btn-"));
170        assert!(chk_id.starts_with("stratum-chk-"));
171        assert_ne!(btn_id, chk_id);
172    }
173
174    #[test]
175    fn current_count() {
176        let id_gen = IdGenerator::new("count");
177        assert_eq!(id_gen.current_count(), 0);
178        id_gen.next();
179        assert_eq!(id_gen.current_count(), 1);
180        id_gen.next();
181        assert_eq!(id_gen.current_count(), 2);
182    }
183}